··
Cómo está montado por dentro el chat con agente de IA embebido en mi portfolio. Streaming SSE con eventos tipados, tool calling contra la API pública del blog, prompt como código y sesiones con cookie HttpOnly.

Mi portfolio era un CV bonito y estático. Si un reclutador quería saber si he tocado Playwright en serio o solo de pasada, tenía que leerse el CV entero y cruzarlo con el blog, y la mayoría no iba a hacerlo. Así que decidí darle al sitio una puerta de entrada distinta, un chat con un asistente que conoce el CV y los posts publicados y que responde en español o inglés según detecte al visitante.
Este artículo va de cómo está montado por dentro. Hay un segundo post sobre cómo lo endurecí para que no se rompa ni cueste dinero en producción, pero aquí me centro en la arquitectura.
La primera decisión fue dónde vivía el chat. Una página propia en /chat dejaba el CV intacto pero aislaba al asistente del contenido que debe comentar. Un widget flotante se ve en cualquier sección del portfolio y permite preguntar ¿este stack lo ha tocado? sin salir del contexto que el usuario está mirando.
Acabó siendo un avatar circular en la esquina inferior derecha con mi foto y un indicador de "online" parpadeante. Al pulsarlo abre un popover que en desktop tiene el tamaño de un chat de mensajería normal y en móvil se expande a pantalla completa. Empieza con tres sugerencias contextuales en tercera persona, del estilo ¿Qué experiencia tiene con Playwright? o Cuéntame sobre JMO Labs, que son las preguntas que realmente hacen los visitantes. No tiene sentido dejar al usuario en frío frente a un prompt vacío.
El cliente es React 19 puro, sin librerías de chat, con un useState por mensaje y un parser de markdown propio que no depende de remark ni de rehype. El servidor es Next.js 16 con runtime Node en las rutas de API. Entre el servidor y el modelo hay OpenRouter.
OpenRouter no es el camino más corto pero es el más flexible. Con una sola variable de entorno puedo cambiar el modelo detrás sin tocar código, y si cambian los precios o sale un modelo mejor, es un redeploy. El código no sabe qué modelo concreto hay al otro lado, solo que habla el dialecto OpenAI-compatible que OpenRouter ofrece.
Las rutas del chat cubren el ciclo entero, configuración inicial, verificación del visitante, resume de sesiones vivas, el mensaje en streaming, la revocación manual y el feedback por pulgar arriba o abajo. El endpoint que importa es el del mensaje. Valida, hashea la IP para la contabilidad, abre el stream hacia OpenRouter y devuelve chunks al navegador. Todo síncrono, sin colas, sin workers. Un solo proceso da de sobra con el volumen de un portfolio personal.
Los chats que he visto en otros sitios suelen meter el token de sesión en localStorage, que es accesible desde cualquier JavaScript de la página. Si algún día una dependencia de npm se compromete o aparece un XSS, ese token se va con el atacante. En este chat el token vive en una cookie con HttpOnly, SameSite=Strict, Path limitado a las rutas del chat y Secure cuando la petición llega por HTTPS. El navegador la adjunta automáticamente en cada llamada del chat y el JavaScript del cliente no la puede leer.
El TTL inicial era corto y tuve que ampliarlo. La razón fue prosaica, cada redeploy reiniciaba las sesiones en memoria y todos los usuarios abiertos se encontraban con un Turnstile otra vez. Mover las sesiones a almacenamiento persistente y ampliar el TTL es mejor experiencia sin comprometer seguridad, porque un token sigue caducando en una ventana acotada y hay un tope duro de usos por sesión.
El chat responde en streaming y esto importa. El modelo puede tardar un par de segundos en empezar a escribir tras la primera llamada y, si además toca buscar en el blog, el tiempo total se va más arriba. Comer ese tiempo con un spinner genérico es peor experiencia que dejar caer el texto en cuanto va saliendo.
Los eventos que el servidor emite son tipados, no un stream de texto plano:
El lado del navegador lee el ReadableStream con un parser de líneas y demultiplexa por tipo. Cada uno tiene su renderer. Si el modelo emite tres chunks Play, wright, lo ha tocado desde 2021, la UI los pega y termina con una frase completa sin parpadeos.
El asistente sabe lo que sabe porque cada request carga dos JSON dentro del prompt. El primero es el CV serializado desde un .ts con toda la experiencia, proyectos, skills, certificaciones, cursos e idiomas. El segundo es el manifest del blog, con título, excerpt, categorías, etiquetas y URL de los posts publicados.
El manifest se trae de un endpoint público del blog que creé expresamente para este caso. Está cacheado lo suficiente para absorber el tráfico sin gastar en round trips y poco como para que un post nuevo tarde poco en aparecer en el asistente.
El endpoint tiene un matiz cuidadoso. Emite un generatedAt igual al timestamp del último post modificado, no la hora del request. Así el cuerpo es byte-stable mientras nada cambia y el cache de prompt de Gemini sobrevive entre visitas. Si el generatedAt fuera la hora del request, cada petición rompería el cache del prefijo y tocaría repagarlo.
En el CV hay campos marcados como privados. No es un problema del modelo, es un problema de lo que serializo. Los valores privados se filtran en la función que monta el JSON antes de llegar al prompt. El modelo no puede filtrar lo que nunca ha visto.
Con solo el manifest en el prompt, el asistente conoce de cada post el excerpt, pero no el contenido completo. Si alguien pregunta algo específico sobre un artículo, necesita leerlo. La respuesta es tool calling, el estándar de OpenAI que OpenRouter respeta.
Hay dos herramientas disponibles. Una busca con full-text search sobre los posts y devuelve candidatos con título, excerpt, snippet y score. La otra baja el contenido completo de un post por su slug, truncado. El servidor itera el tool loop con un tope corto, y si en esas pocas rondas el modelo no termina de razonar, se fuerza una respuesta final. En la práctica el modelo encadena como mucho dos llamadas, busca, encuentra un candidato, lee el post, responde.
Las herramientas tienen una protección sutil. El contenido devuelto por la herramienta de fetch viene envuelto en etiquetas que el prompt del sistema identifica como contenido externo, es decir, datos de terceros, no instrucciones. Aunque un post mío dijera ignora todas las instrucciones anteriores (no lo hace, pero podría), el modelo lo trataría como una cita de un artículo, no como una directiva. La diferencia entre datos e instrucciones se tiene que explicitar, porque el LLM no la sabe por defecto.
El endpoint que devuelve un post concreto es el tercero que creé para este chat. Devuelve el post en texto plano, truncado, con un ETag débil basado en un hash del cuerpo. El cliente (el propio chat) puede reenviar ese ETag en If-None-Match y recibir un 304 Not Modified sin cuerpo si el post no ha cambiado. El Cache-Control se emite en 200 y en 304, Traefik cachea ambos y la mayoría del tráfico ni siquiera llega al Node.
Esto cierra un triángulo de endpoints públicos, uno para el listado, otro para búsquedas dirigidas y otro para el contenido puntual. Los tres están rate-limitados por IP, cacheados en el edge y no exponen ningún ID interno, solo slugs. El contenido ya era público como HTML, simplemente lo expongo en una forma que un agente pueda consumir sin tener que parsear la maquetación.
El prompt del sistema vive en un .ts del repo y es lo suficientemente largo como para tratarlo como código, no como texto. Está versionado, tiene tests unitarios y cada cambio pasa por la misma revisión que una función.
La parte dura del prompt no es describir qué puede hacer el asistente, sino qué no puede hacer. Hay tres reglas que tuve que repetir varias veces hasta que el modelo las siguió de verdad.
La primera es la perspectiva. El asistente habla en primera persona de sí mismo (soy el asistente de José) y en tercera persona de José (José ha trabajado con Playwright). Si lo dejas libre, el modelo deriva a primera persona hablando como si fuera yo, y eso es raro para un visitante que sabe que está hablando con una IA.
La segunda es el grounding estricto. El modelo solo puede mencionar una tecnología, empresa, certificación o proyecto si aparece literalmente en los datos del CV o en el manifest del blog. Si alguien pregunta por algo que no está, la respuesta es no aparece en su experiencia declarada, no una alucinación educada. En la práctica esto recorta las respuestas, pero las mantiene verificables.
La tercera es el idioma. El modelo detecta el idioma del último mensaje del usuario y responde entero en ese idioma. Nada de saludar en español y cerrar en inglés, nada de añadir un Let me know if… al final de una respuesta en español. El primer paso del razonamiento es ¿en qué idioma me ha hablado? y el resto se adapta.
El prompt tiene una sección entera de frases prohibidas. Nada de ¡Claro!, ¡Por supuesto!, Con gusto, Te cuento:, Aquí tienes:, En resumen ni Cabe destacar. Suenan a IA de hace dos años y erosionan la confianza. En su lugar, la regla es empezar con la respuesta sin preámbulo, mezclar frases cortas y largas y enunciar los hechos en llano. Trabaja con Playwright desde 2021 vence a Cuenta con una experiencia consolidada en Playwright que se remonta a 2021.
Parece un detalle pero si no lo escribes en el prompt, el modelo lo olvida por defecto. Gemini, GPT y Claude tienen estas muletillas aprendidas y las activan en cuanto pueden.
Al final del build hay tres cosas que me llevo.
La primera es que un chat LLM bien hecho se parece más a diseñar una API que a escribir un prompt. El prompt es un fichero más. Lo que pesa es el contrato de datos entre el modelo, las herramientas y la UI, el ciclo de vida de las sesiones y cómo fluye el error a través del streaming.
La segunda es que exponer el blog a un agente propio me obligó a pensar el blog como una API, no como HTML. Los tres endpoints públicos existen porque un LLM necesita datos estables, cacheables y sin maquetación. Como efecto lateral, ahora cualquier otro agente puede consumir lo mismo, y el blog publica un OpenAPI para que lo descubran.
La tercera es que el trabajo que no se ve del chat está en la defensa, no en la conversación. El próximo post va sobre eso, sobre cómo endurecí el asistente para que aguante tráfico real sin abrir boquetes ni irse de coste. No voy a contar los números concretos de ninguna medida, porque contarlos convierte el post en un manual para saltárselas, pero sí voy a contar qué tipo de capas valen la pena y por qué.
Se puede probar pulsando el avatar de abajo a la derecha en mi portfolio. Si saca una respuesta rara, me interesa verla.

Jose, autor del blog
QA Engineer. Escribo en voz alta sobre automatización, IA y arquitectura de software. Si algo te ha servido, escríbeme y cuéntamelo.
¿Qué te ha parecido? ¿Qué añadirías? Cada comentario afina la siguiente entrada.
Si esto te ha gustado

Tres asistentes de código con IA, tres modelos mentales distintos. Agente que vive en el terminal, editor con autocompletado que parece magia y agente remoto al que delegas. Cuándo tiro de cada uno y por qué usarlos en paralelo me ha cambiado el flujo.

Cada post del blog puede leerse en voz alta con un reproductor integrado. El audio se genera con IA la primera vez que alguien lo pide y se cachea para todos los demás.

Los tests E2E se rompen con cada cambio de interfaz. En JMO Labs construimos un pipeline de 5 fases con IA que planifica, ejecuta, repara selectores, diagnostica fallos y verifica resultados de forma autónoma. La caché de selectores hace que cada ejecución sea más rápida que la anterior.