Category: Blog

Your blog category

  • Visor de PDF en Next.js con Apryse WebViewer: guía real

    Visor de PDF en Next.js con Apryse WebViewer: guía real

    Un cliente me escribió con lo que parecía un encargo de dos semanas.

    «Necesitamos que el usuario abra su contrato dentro de la plataforma, lo anote, tache los datos sensibles y lo firme. Sin descargar nada, sin salir de la app.»

    Dos semanas. Claro.

    Lo que estaba pidiendo era un visor de PDF en Next.js con capa de anotaciones persistentes, redacción de verdad —no un rectángulo negro pintado encima, sino borrar el texto del documento— y firma. Es decir: un producto entero metido dentro de una ruta de la aplicación.

    Lo he visto intentar a mano tres veces. Las tres acabaron igual: seis meses después seguían peleando con fuentes embebidas y zoom en móvil.


    Resumen: visor de PDF en Next.js en 5 puntos

    • Un visor de PDF en Next.js es un componente de cliente que renderiza documentos dentro de tu aplicación, sin descargarlos ni delegar en el visor nativo del navegador. Requiere dos cosas que no son obvias: cargar la librería solo en el navegador y servir sus assets estáticos desde public/.
    • Construir a mano anotaciones, redacción y firma sobre PDF es un pozo sin fondo: el formato tiene 30 años de casos borde.
    • Apryse WebViewer (@pdftron/webviewer) te da ese flujo completo en el navegador, sin backend de por medio.
    • Es un SDK comercial con trial gratuito. Los paquetes de entrada arrancan en $1.500 según su web de precios —consultado en julio de 2026—, y el precio final es a medida.
    • Si solo necesitas mostrar un PDF, no lo uses. pdf.js o react-pdf te resuelven eso gratis.

    Por qué "solo un visor de PDF" nunca es solo un visor

    Un PDF no es una imagen con texto: es un contenedor con tipografías embebidas, capas, formularios AcroForm, XFA, firmas criptográficas, anotaciones con su propio modelo de datos y treinta años de decisiones heredadas. Parece simple solo porque lo abres todos los días y funciona.

    Renderizar la primera página con pdf.js te lleva una tarde. El problema empieza después.

    Que la anotación quede anclada al párrafo correcto al hacer zoom. Que la redacción elimine el texto del stream y no solo lo tape —si lo tapas, cualquiera lo copia con Ctrl+C y tienes un incidente de datos—. Que la firma se incruste sin romper la validez del documento. Y que todo eso funcione igual en Safari iOS.

    Ese es el trabajo real. Y no es trabajo de dos semanas: es trabajo de un equipo dedicado durante trimestres.

    Antes de meter una pieza así en tu aplicación, escribe qué necesitas exactamente. Suena obvio y casi nadie lo hace. En cómo aplico Spec-Driven Development antes de escribir código explico el proceso —y lo tienes entero en el libro de Spec-Driven Development—. Esta es justo la decisión donde una especificación de una página te evita elegir mal una licencia comercial.


    Qué es Apryse WebViewer y qué te ahorra

    Apryse WebViewer es un SDK comercial que monta un visor y editor de PDF en React dentro de tu aplicación, ejecutándose entero en el navegador. No necesitas un servicio de conversión detrás.

    Lo que trae de fábrica:

    • Anotación completa: resaltados, notas, dibujo, formas, comentarios.
    • Redacción real, que elimina el contenido del documento.
    • Edición de texto sobre el PDF.
    • Fill & sign para formularios y firma.
    • Búsqueda dentro del documento.
    • Cambio programático del documento cargado.
    • Soporte de más de 100 formatos: Office, imágenes, CAD. No solo PDF.

    Ese último punto suele cerrar la decisión. Cuando el cliente añade «ah, y también suben Word y planos», ya no evalúas un visor: evalúas si escribes tu propio pipeline de conversión.


    Cómo montar un visor de PDF en Next.js paso a paso

    La guía oficial de Apryse para Next.js cubre la instalación y es correcta. Lo que sigue añade lo que no te cuenta: la limpieza al desmontar, el fallo silencioso cuando path está mal y en qué casos no deberías estar leyendo este tutorial.

    1. Instalar el paquete

    npm i @pdftron/webviewer@^12
    

    Fijar la mayor te evita que un npm update te cambie la API por debajo. Este tutorial está escrito sobre la rama 12 con Next.js 16 y App Router.

    2. Copiar los assets estáticos

    Este es el paso que más gente se salta y luego pasa una tarde mirando 404 en la pestaña de red. WebViewer necesita sus propios archivos servidos estáticamente: workers, fuentes, recursos de UI.

    npx --yes cpy-cli "node_modules/@pdftron/webviewer/public/**/*" public/lib/webviewer
    

    Guárdalo como script de postinstall. Si no lo haces, funcionará en tu máquina y fallará en el primer despliegue limpio de CI:

    {
      "scripts": {
        "postinstall": "npx --yes cpy-cli \"node_modules/@pdftron/webviewer/public/**/*\" public/lib/webviewer"
      }
    }
    

    3. El componente, siempre en cliente (y el error "window is not defined")

    WebViewer toca window y el DOM en el momento de inicializarse. Si Next.js intenta renderizarlo en el servidor, revienta con el clásico ReferenceError: window is not defined.

    Aquí está el matiz que atasca a casi todo el mundo: marcar el componente con 'use client' no basta si el import es estático. Esa directiva define dónde se hidrata el componente, no impide que el módulo se evalúe al construir el bundle del servidor.

    La solución es importar el módulo dinámicamente dentro del useEffect, para que solo se resuelva en el navegador después del montaje.

    'use client'
    import { useEffect, useRef } from 'react'
    
    export default function PdfViewer() {
      const viewer = useRef(null)
    
      useEffect(() => {
        const container = viewer.current
        let instance = null
        let unmounted = false
    
        const destroy = () => {
          instance?.UI?.dispose?.()
          instance = null
          if (container) container.innerHTML = ''
        }
    
        import('@pdftron/webviewer')
          .then(({ default: WebViewer }) =>
            WebViewer(
              {
                path: '/lib/webviewer',
                licenseKey: process.env.NEXT_PUBLIC_APRYSE_LICENSE_KEY,
                initialDoc: 'https://apryse.s3.amazonaws.com/public/files/samples/WebviewerDemoDoc.pdf',
              },
              container,
            ),
          )
          .then((i) => {
            instance = i
            if (unmounted) return destroy()
            i.Core.documentViewer.addEventListener('documentLoaded', () => {
              // el documento ya está en pantalla: engancha aquí tu lógica
            })
          })
          .catch((error) => {
            console.error('WebViewer no arrancó. Revisa la opción `path`:', error)
          })
    
        return () => {
          unmounted = true
          destroy()
        }
      }, [])
    
      return <div ref={viewer} style={{ height: '100dvh' }} />
    }
    

    Cuatro detalles que conviene entender, no copiar:

    • path apunta exactamente a donde copiaste los assets en el paso 2. Si cambias la carpeta, cambia esto.
    • El array de dependencias vacío no te salva de StrictMode. En desarrollo React monta, desmonta y vuelve a montar: el efecto corre dos veces y, sin función de limpieza, acabas con dos visores peleando por el mismo div. Por eso el return del useEffect llama a UI.dispose() y vacía el contenedor. Es la parte que casi ningún tutorial escribe.
    • La bandera unmounted cubre el otro orden posible: que el usuario navegue a otra ruta antes de que resuelva el import() dinámico. Sin ella te quedas un iframe y unos workers WASM vivos en memoria.
    • La promesa resuelve con un instance que expone instance.Core —donde vive documentViewer— e instance.UI. Ese objeto es tu mando a distancia.

    Y sí, el .catch() importa: si path está mal, sin él la promesa se rechaza en silencio y te quedas mirando un div en blanco sin una sola pista en consola.

    Si necesitas la API completa, añade fullAPI: true a las opciones.

    La clave va en variable de entorno para no hardcodearla en el repo:

    NEXT_PUBLIC_APRYSE_LICENSE_KEY=tu_clave_de_trial
    

    Ojo con la etiqueta: cualquier variable NEXT_PUBLIC_ viaja al bundle del navegador, así que esto no la convierte en un secreto. En WebViewer la licencia es de cliente y va ligada a tu dominio, así que es correcto y esperado; pero si algún día metes aquí una credencial de verdad, ese secreto vive en tu backend, no en una NEXT_PUBLIC_.


    Controlar el visor desde tu propia interfaz

    Casi nadie quiere la UI del SDK tal cual: quieres tus botones, con tu marca, en tu layout.

    El patrón es guardar la instancia en estado o en una ref y llamar a sus métodos desde tus componentes. El visor deja de ser una caja negra y pasa a ser un motor que tú comandas.

    'use client'
    import { useEffect, useRef, useState } from 'react'
    
    export default function PdfWorkspace() {
      const viewer = useRef(null)
      const [instance, setInstance] = useState(null)
    
      useEffect(() => {
        const container = viewer.current
        let current = null
        let unmounted = false
    
        const destroy = () => {
          current?.UI?.dispose?.()
          current = null
          setInstance(null)
          if (container) container.innerHTML = ''
        }
    
        import('@pdftron/webviewer')
          .then(({ default: WebViewer }) =>
            WebViewer(
              {
                path: '/lib/webviewer',
                licenseKey: process.env.NEXT_PUBLIC_APRYSE_LICENSE_KEY,
                fullAPI: true,
              },
              container,
            ),
          )
          .then((i) => {
            current = i
            if (unmounted) return destroy()
            setInstance(i)
          })
          .catch((error) => {
            console.error('WebViewer no arrancó:', error)
          })
    
        return () => {
          unmounted = true
          destroy()
        }
      }, [])
    
      return (
        <>
          <button
            disabled={!instance}
            onClick={() => {
              if (!instance) return
              const { documentViewer } = instance.Core
              // llama aquí al método que necesites sobre el documento
            }}
          >
            Acción propia
          </button>
          <div ref={viewer} style={{ height: '100dvh' }} />
        </>
      )
    }
    

    Un aviso de rendimiento: este bundle es grande. No lo cargues en el layout raíz ni en una ruta que la gente visita de paso. Aíslalo en su propia ruta y deja que el router precargue lo demás — hablé de esto al analizar las navegaciones instantáneas de Next.js 16.3, y aquí la diferencia entre hacerlo bien y mal se nota en segundos, no en milisegundos.


    La parte incómoda: cuánto cuesta Apryse WebViewer

    Apryse WebViewer no es gratis: su web de precios indica paquetes de entrada desde $1.500, y el precio final es a medida. Esa es la cifra, y aquí es donde la mayoría de tutoriales se callan.

    El importe depende de las features que actives, del volumen de documentos y de si la solución es cliente o servidor. No hay tarifa pública por tramos: hay que pedir presupuesto.

    Antes de pagar puedes probarlo. Apryse ofrece un trial gratuito, y su documentación de instalación te pide obtener una trial key en el portal de desarrolladores como paso previo. No te fíes de las condiciones que leas en un blog —incluido este—: mira el portal, que es donde cambian.

    Vas a encontrar otras cifras circulando por foros y comparativas. Ninguna sale de Apryse. Ignóralas y pide presupuesto: es la única cifra que vale para tu caso.


    ¿Cuándo NO usar Apryse?

    Si lo único que necesitas es mostrar un PDF en modo lectura, esto es un cañón para matar una mosca.

    Para eso tienes pdf.js de Mozilla, o react-pdf —construido encima— si quieres la integración con componentes ya resuelta. Son gratis, open source, maduros y te resuelven ese caso entero. Meter un SDK comercial ahí es quemar presupuesto y añadir peso al bundle sin ganar nada.

    Esta es la comparativa que a mí me habría ahorrado dos días de evaluación:

    Necesidad pdf.js react-pdf Apryse WebViewer
    Renderizar, paginar, zoom
    Búsqueda en el documento ⚠️ manual
    Componentes React listos
    Anotaciones persistentes
    Redacción real (borra del stream)
    Edición de texto sobre el PDF
    Fill & sign / firma
    Office, imágenes, CAD (100+ formatos)
    Licencia Apache 2.0 MIT Comercial
    Coste Gratis Gratis Desde $1.500, a medida

    Léela en diagonal y verás el patrón: las tres primeras filas son un empate, y todo lo demás es una columna sola. Si tu requisito vive en las tres primeras filas, ya tienes tu respuesta y es gratis.

    Apryse gana en un escenario concreto: cuando el documento es parte del flujo de negocio. Cuando el usuario tiene que anotar, redactar, rellenar, firmar o editar, y ese flujo es lo que el cliente está pagando.

    Y ahí el cálculo no es "SDK caro contra librería gratis". Es esto: cuántos meses de ingeniería cuesta construir y mantener redacción, anotaciones y firma bien hechas, contra el precio de licenciarlo.

    Cuando lo planteas así, la respuesta suele ser evidente. El coste no es el SDK. Es el tiempo que no gastas.


    Qué hacer hoy

    Instala el trial, copia los assets, monta el componente de arriba y ábrelo con un PDF real de tu cliente. No el de ejemplo: uno feo, escaneado, de 80 páginas.

    En veinte minutos sabrás si esto resuelve tu problema o si te sobra con react-pdf. Esa decisión, tomada con el visor delante y no leyendo comparativas, vale más que cualquier post.

    Y si lo que quieres es integrar piezas grandes como esta sin perder tres días leyendo documentación, ese es exactamente el flujo que enseño en el curso Construye con IA: especificar primero, delegar la integración después. La metodología completa está en el libro de Spec-Driven Development, y si prefieres hacerlo acompañado, en Dominicode Labs trabajamos integraciones como esta sobre proyectos reales.


    Preguntas frecuentes

    ¿Apryse WebViewer es gratis?

    No. Es un SDK comercial. Sí ofrece un trial gratuito para validar si encaja con tu caso: su documentación de instalación te pide obtener una trial key en el portal de desarrolladores antes de empezar. Para producción, su web de precios indica paquetes de entrada desde $1.500 (consultado en julio de 2026), con precio final a medida según las features que actives, el volumen de documentos y si el despliegue es en cliente o en servidor. Las condiciones exactas del trial las marca su portal, no los blogs.

    ¿Se puede usar Apryse WebViewer con el App Router de Next.js?

    Sí, con dos condiciones. El componente que lo monta debe llevar la directiva 'use client' y el import del paquete tiene que ser dinámico dentro del useEffect, no estático en la cabecera del archivo. Así el bundle del servidor nunca evalúa el SDK. Además tienes que copiar los assets estáticos del paquete a public/lib/webviewer y apuntar la opción path a esa ruta.

    ¿Por qué me da el error "window is not defined" al integrar el visor?

    Porque Next.js está intentando ejecutar el SDK durante el renderizado en servidor, donde no existe el objeto window. Marcar el componente como cliente no basta si el import es estático: el módulo se evalúa igualmente al construir. La solución es cargarlo con import('@pdftron/webviewer') dentro del useEffect, de forma que solo se resuelva en el navegador después del montaje.

    ¿Qué alternativa gratuita hay a Apryse?

    pdf.js de Mozilla y react-pdf, que está construido encima. Ambos son open source y cubren perfectamente la visualización de documentos: renderizado, paginación, zoom y búsqueda básica. Donde no llegan es en el flujo completo de trabajo con documentos —redacción que borra contenido de verdad, edición de texto, fill & sign, anotaciones persistentes con su modelo de datos—. Si tu requisito es leer, usa las gratuitas. Si tu requisito es operar sobre el documento, compara con Apryse.

    ¿Sirve solo para PDF?

    No. WebViewer soporta más de 100 formatos, incluidos documentos de Office, imágenes y archivos CAD, y los renderiza en el mismo visor sin necesidad de un servicio de conversión en el servidor. Suele ser el factor decisivo cuando los usuarios suben lo que tienen a mano y no un PDF bien generado.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Llevo 15 años programando: esto es lo que cambió con la IA

    Llevo 15 años programando: esto es lo que cambió con la IA

    Hace quince años, construir una funcionalidad significaba abrir un archivo en blanco y teclear cada línea hasta que compilaba. Cuando me atascaba, Stack Overflow. Cuando Stack Overflow fallaba, la documentación. Cuando la documentación mentía, prueba y error durante horas. Así aprendí el oficio y así trabajé la primera mitad de mi carrera.

    Esta mañana he construido un módulo completo sin teclear una sola línea de implementación a mano.

    Llevo quince años en esto y he visto pasar muchas modas. El desarrollo de software con IA no es una más. Es lo único que ha cambiado de raíz cómo hago mi trabajo. Pero no por la razón que casi todo el mundo repite en LinkedIn.

    Lo que ha cambiado no son las herramientas. Es el rol.

    Ya no me pagan por escribir código. Me pagan por decidir qué código debe existir, especificarlo bien y verificar que lo que se ha escrito es correcto. El tecleo —la parte que durante quince años fue la mayor parte del oficio— se ha vuelto la parte barata.

    Y si quieres la definición limpia, esta es la mía: el desarrollo de software con IA es la práctica de construir software delegando la escritura del código a modelos y agentes, mientras el developer se reserva las tres decisiones que siguen siendo suyas —qué construir, cómo debe encajar y si lo generado es correcto—.


    De escribir código a orquestarlo: así se programa con IA hoy

    Antes, un día productivo se medía en líneas. Hoy se mide en decisiones acertadas.

    La sesión de esta mañana fue así: abrí un documento, describí qué quería —el comportamiento, los límites, los casos que no debía tocar—, se lo pasé a un agente y me fui a por café. Cuando volví, había un diff de trescientas líneas esperándome.

    Mi trabajo empezó ahí. Leerlo entero. Cuestionar tres decisiones. Rechazar una. Aprobar el resto.

    No escribí la implementación. La orquesté.

    Si tuviera que resumir el cambio en una tabla, sería esta:

    Antes Ahora
    Unidad de medida Líneas escritas Decisiones acertadas
    Cuello de botella Teclear rápido y conocer la API Especificar con precisión
    Habilidad clave Saber escribir código Saber leer y revisar código
    Riesgo principal Bugs por descuido Deuda por código que nadie entendió
    Tu rol Autor Director y revisor

    Y ese cambio no fue de un día para otro. Fue una escalera. Primero el autocompletado —GitHub Copilot en 2021—, que adivinaba el final de la línea. Después el chat, ChatGPT y compañía, al que le pegabas un error y te devolvía una respuesta plausible. Y ahora el agente autónomo, del estilo de Claude Code o Cursor, que lee tu repo, ejecuta comandos, mira la salida y decide el siguiente paso sin ti. Si todavía andas en el primer escalón, la guía de Agentes de IA es el mejor sitio para entender qué hace distinto al último.

    Esa forma de trabajar en bucle —delegar, observar, corregir, repetir— tiene su propia disciplina, y la desarrollé entera en Loop Engineering: la evolución del desarrollo con IA. Porque diseñar bien ese bucle es hoy más determinante que elegir el modelo de moda.


    El cuello de botella del desarrollo de software con IA se movió: ahora está en especificar

    Durante años, el cuello de botella era teclear rápido y conocer la API de memoria. El que escribía más limpio y más rápido ganaba.

    Hoy el cuello de botella es otro: describir con precisión lo que quieres.

    Un agente hace lo que le pides al pie de la letra, no lo que querías decir. Todo lo que no especificas, lo inventa. Y lo inventa con una seguridad que asusta.

    Por eso el trabajo de más valor ya no es escribir la función. Es escribir la especificación de la función: el resultado esperado, los límites, los casos borde, lo que queda explícitamente fuera del alcance.

    Esto no es teoría. Es la metodología que uso a diario y la que documenté entera en el libro de Spec-Driven Development: especificar primero, delegar después. Si quieres el porqué antes que el cómo, lo cuento en Spec-Driven Development: la forma de evitar el caos con la IA.

    El developer que sabe redactar una buena especificación multiplica su trabajo. El que sigue tratando al agente como un buscador —"hazme esto"— se pasa el día corrigiendo basura.


    Lo que NO ha cambiado (y por qué el senior vale más que nunca)

    Aquí está la parte incómoda para los que venden que la IA ya programa sola.

    Nada de esto elimina al developer con criterio. Lo hace imprescindible.

    Un agente escribe trescientas líneas en dos minutos. Pero no sabe si esas trescientas líneas encajan en tu arquitectura. No sabe si van a ser un infierno de mantener dentro de un año. No sabe si acaba de duplicar una lógica que ya existía en otro módulo. El agente optimiza para que el criterio de parada se cumpla, no para que el sistema siga vivo dentro de dos años.

    Ese juicio sigue siendo tuyo.

    Y no es una manía mía de señor mayor. El informe DORA 2025 de Google Cloud, hecho con cerca de 5.000 profesionales de todo el mundo, encontró que el 90% ya usa IA en su trabajo y más del 80% dice que le ha subido la productividad. Pero un 30% reconoce tener poca o ninguna confianza en el código que esa IA genera.

    Ahí tienes la foto exacta del oficio hoy: casi todos delegamos, casi nadie firma a ciegas. Esa distancia entre "lo uso todos los días" y "no me fío" es, literalmente, la descripción de tu nuevo puesto de trabajo.

    Y ojo con confundir velocidad con progreso. Generar código rápido no es lo mismo que avanzar rápido. Un diff de trescientas líneas que nadie entiende no es velocidad, es deuda con intereses. Por eso "más rápido" y "mejor" no son la misma métrica, y desarrollé cómo distinguirlas en Cómo medir la productividad de un equipo con IA.

    Hay tres cosas que la IA no ha tocado, y son exactamente las que definen a un buen ingeniero:

    • El criterio. Saber qué construir y, sobre todo, qué no construir.
    • La arquitectura. Decidir cómo encajan las piezas para que el sistema aguante el paso del tiempo.
    • Saber leer código. Porque revisar es la nueva forma de escribir. Un diff que no entiendes es un diff que no puedes aprobar.

    Lo diré claro: hoy saber leer código importa más que saber escribirlo. Escribir lo hace la máquina. Leerlo, entenderlo y detectar dónde se ha equivocado sigue siendo humano.


    Al que no se adapta no lo sustituye la IA

    El miedo que oigo en cada charla es siempre el mismo: "¿La IA me va a quitar el trabajo?".

    No. Pero un developer que orquesta, especifica y revisa bien va a hacer el trabajo de tres que siguen tecleando línea a línea. Y las empresas lo van a notar en la nómina antes de lo que crees.

    No te sustituye la IA. Te sustituye el compañero que sabe usarla.

    La brecha ya no está entre el que programa y el que no. Está entre el que ha movido su trabajo hacia arriba en la cadena —del tecleo a la decisión— y el que sigue midiendo su día en líneas escritas a mano, orgulloso de un esfuerzo que la máquina hace gratis.

    Esa segunda persona no está en peligro por la IA. Está en peligro por negarse a cambiar de rol.


    Qué puedes hacer hoy

    Si llevas años programando y sientes que el suelo se mueve, tienes razón. Se mueve. Pero a tu favor, si haces el cambio a tiempo.

    Deja de medir tu jornada en líneas escritas. Empieza a medirla en decisiones acertadas, especificaciones claras y diffs bien revisados.

    Coge mañana una tarea aburrida y acotada —migrar un módulo, añadir tests a un servicio— y en vez de teclearla, especifícala y delégala. Luego siéntate a revisar el resultado como revisarías el pull request de un junior brillante pero despistado. Ahí, en esa revisión, es donde vas a hacer tu trabajo de senior a partir de ahora.

    Ese es el músculo nuevo. Y como todo músculo, se entrena.

    Si quieres ver este flujo completo montado de principio a fin —de la idea a un producto funcionando, especificando y delegando de verdad— es exactamente lo que construimos en el curso Construye con IA. Y si prefieres hacer el cambio acompañado, con proyectos reales y gente que ya está en esto, te espero en Dominicode Labs.

    El código dejó de ser el trabajo. El criterio para dirigirlo es el trabajo. Muévete hacia ahí.


    Preguntas frecuentes

    ¿La IA va a reemplazar a los programadores?

    No a los programadores con criterio. La IA reemplaza el tecleo, que era la parte mecánica del oficio, no el juicio. Un agente escribe código muy rápido, pero no decide qué construir, no diseña una arquitectura que aguante el tiempo ni sabe si su propia solución es mantenible. Lo que sí ocurre es que un developer que sabe orquestar, especificar y revisar hace el trabajo de varios que siguen escribiendo cada línea a mano. El riesgo no es la IA: es no adaptarse a usarla.

    ¿Necesito seguir aprendiendo a programar si la IA escribe el código?

    Sí, y hoy más que nunca. La IA escribe código, pero alguien tiene que leerlo, entenderlo y decidir si es correcto. No puedes aprobar un diff que no comprendes ni detectar un fallo de arquitectura si no sabes cómo debería estar construido. Saber programar deja de ser una habilidad de producción y pasa a ser una habilidad de criterio y revisión. Sin esa base, delegar en un agente es apostar a ciegas.

    ¿Por dónde empiezo a programar con IA?

    Por una tarea real, aburrida y acotada, no por un proyecto ambicioso. Coge algo que sepas hacer a mano en media hora —migrar un módulo, añadir tests, actualizar una dependencia—, escríbele una especificación clara al agente en lugar de un "hazme esto" y luego revisa el resultado línea a línea. Cuando eso te salga limpio, sube el listón. Trabajar primero la especificación y después delegar es la base de la metodología Spec-Driven Development, y es el orden que evita el caos.

    ¿Qué habilidades necesita hoy un developer?

    Tres que la IA no cubre. Criterio para decidir qué construir y qué no. Arquitectura para que las piezas encajen y el sistema sobreviva al paso del tiempo. Y capacidad de leer código ajeno —ahora, código generado— para revisarlo y aprobarlo con confianza. A eso se suma una habilidad nueva: saber especificar con precisión lo que quieres, porque todo lo que no le dices al agente, se lo inventa. El tecleo rápido ya no está en la lista.

    ¿Sigue haciendo falta un developer senior si la IA programa sola?

    Más que antes. La IA baja el coste de escribir código, lo que multiplica la cantidad de código que se genera y, con él, la superficie donde algo puede salir mal. Alguien tiene que poner criterio arquitectónico, revisar lo que produce el agente y frenar las decisiones que optimizan por cerrar la tarea a costa de la mantenibilidad. Ese trabajo es exactamente el de un senior. La IA no elimina ese rol: lo hace el más valioso del equipo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Claude Opus 5: los 2 breaking changes que rompen tu código

    Claude Opus 5: los 2 breaking changes que rompen tu código

    Cambias una línea. Un campo model dentro de un JSON. Diez segundos de trabajo, deploy a staging, a otra cosa.

    Veinte minutos después el endpoint de resúmenes devuelve textos cortados a mitad de frase. Y una ruta concreta —la de análisis largo, la que más te importa— devuelve 400 sin que hayas tocado nada más.

    No es un bug de Anthropic. Eres tú, migrando a Claude Opus 5 como si fuera un cambio de versión menor.

    No lo es. Y el problema es que casi todo lo que vas a leer estos días sobre este modelo son tablas de benchmarks. El titular es que cuesta la mitad que Fable 5. La letra pequeña es que si no tocas dos parámetros, tu aplicación empieza a devolver respuestas cortadas y errores 400.

    Este post va de la letra pequeña.


    Qué es Claude Opus 5 y qué cambia respecto a Opus 4.8

    Claude Opus 5 es el modelo más capaz de Anthropic, disponible desde el 24 de julio de 2026 con el identificador de API claude-opus-5, sin sufijo de fecha. Cuesta $5 por millón de tokens de entrada y $25 de salida —el mismo precio que Opus 4.8— y trae dos breaking changes respecto a la generación anterior: el thinking viene activado por defecto y desactivarlo deja de ser compatible con los niveles de effort xhigh y max.

    Atributo Claude Opus 5 Claude Opus 4.8
    ID de API claude-opus-5 claude-opus-4-8
    Precio entrada / salida $5 / $25 por millón $5 / $25 por millón
    Fast mode (solo API de Anthropic) $10 / $50 por millón
    Thinking al omitir el parámetro Adaptativo, activado Desactivado
    Qué cubre max_tokens Thinking + respuesta Solo la respuesta
    Effort por defecto en la API high
    thinking: disabled + xhigh/max HTTP 400 Válido
    Mínimo para prompt caching 512 tokens 1024 tokens
    Ventana de contexto 1M tokens (defecto y máximo)
    Salida máxima 128K tokens
    Rate limits Cubo propio Pool combinado Opus 4.x

    Está disponible en Claude.ai, Claude Code, Claude Cowork, la API de Anthropic, Amazon Bedrock (anthropic.claude-opus-5), Google Cloud y Microsoft Foundry. Es el modelo por defecto en Claude Max y el más potente disponible en Claude Pro. Los datos de esta tabla están contrastados con la documentación oficial de Anthropic.


    Los benchmarks de Claude Opus 5, en treinta segundos

    Sí, los números son buenos. Los despacho rápido porque no son el tema.

    En CursorBench 3.2, a máximo effort, Claude Opus 5 se queda a un 0,5% del pico de Fable 5 —a la mitad de coste por tarea—. En ARC-AGI 3 triplica la puntuación del siguiente mejor modelo. En Frontier-Bench v0.1 más que dobla el rendimiento de Opus 4.8. Los tres resultados salen de las cifras publicadas por Anthropic.

    No es el mejor en todo: sigue por detrás de Mythos 5 en tareas de ciberseguridad ofensiva. Y Anthropic lo describe como su modelo mejor alineado hasta la fecha, con la menor tasa de comportamiento engañoso.

    Si vienes de Claude Opus 4.8, pagas lo mismo por token por un modelo bastante mejor. Con un matiz que casi nadie menciona: Opus 5 piensa por defecto y escribe más largo, así que gasta más tokens por tarea. Misma tarifa no significa misma factura. Y si estabas pagando el premium de Fable 5 por tareas de agente, ahí sí: Anthropic mide la mitad de coste por tarea.

    Perfecto. Ahora la parte que rompe cosas.


    Breaking change 1 de Claude Opus 5: el thinking viene activado por defecto

    En Claude Opus 5, omitir el parámetro thinking ejecuta thinking adaptativo; en Opus 4.8 y 4.7 omitirlo significaba no razonar. Este es el cambio que corta tus respuestas a mitad de frase.

    Antes, el silencio equivalía a "no razones, contéstame". Ahora el silencio es un sí.

    Y aquí viene la parte que duele: max_tokens es un tope duro sobre thinking más texto de respuesta, juntos. No son dos presupuestos separados.

    Si tenías max_tokens ajustado al milímetro para tu respuesta —y todo el que ha optimizado costes lo tiene ajustado al milímetro— el modelo se gasta parte de ese presupuesto razonando y la respuesta se corta.

    Este código funcionaba perfectamente ayer:

    import Anthropic from "@anthropic-ai/sdk";
    const client = new Anthropic();
    
    // Opus 4.8 — omitir "thinking" = sin razonamiento; 1024 tokens íntegros para la respuesta
    const res = await client.messages.create({
      model: "claude-opus-4-8",
      max_tokens: 1024,
      messages: [{ role: "user", content: prompt }],
    });
    

    Cambias el model a claude-opus-5 y esos 1024 tokens ahora se reparten entre razonamiento y respuesta. Nadie te avisa: no hay error, solo un texto que termina a media frase.

    Tienes dos salidas. La buena:

    // Opus 5 — thinking explícito y presupuesto con margen
    const res = await client.messages.create({
      model: "claude-opus-5",
      max_tokens: 8192, // cubre thinking + respuesta
      thinking: { type: "adaptive", display: "summarized" },
      output_config: { effort: "medium" },
      messages: [{ role: "user", content: prompt }],
    });
    

    Y la que replica el comportamiento anterior:

    thinking: { type: "disabled" }
    

    Cuidado con esa segunda, porque tiene trampa. Es exactamente el breaking change número dos.

    Sobre display: los tokens de razonamiento en crudo no se devuelven nunca. El valor por defecto es "omitted". Si pones "summarized" recibes un resumen legible del razonamiento, útil para logs y para depurar por qué el modelo llegó a donde llegó.


    Breaking change 2: desactivar el thinking en Opus 5 está capado a effort high

    Esta es la que devuelve 400.

    En Opus 5 la escala completa de effort es low, medium, high, xhigh y max. El valor por defecto de la API es high.

    Combinar thinking: { type: "disabled" } con effort xhigh o max devuelve HTTP 400. En Opus 4.8 esa combinación era perfectamente válida.

    // Válido en Opus 4.8 — error 400 en Opus 5
    const res = await client.messages.create({
      model: "claude-opus-5",
      max_tokens: 4096,
      thinking: { type: "disabled" },
      output_config: { effort: "xhigh" }, // 400
      messages: [{ role: "user", content: prompt }],
    });
    

    Y ahora el detalle que hace que esto sea peligroso de verdad: la validación es por petición. No hay un chequeo global al arrancar. Puedes tener veinte llamadas funcionando con thinking desactivado y effort high, y que la veintiuna —la que sube a xhigh para el caso difícil— se rechace. Las anteriores funcionando no te protegen de nada.

    Traducido: cualquier ruta de tu código que desactive el thinking hay que auditarla antes de migrar, no después. Búscalo con un grep por "disabled" y revisa qué effort viaja en cada una de esas peticiones.

    Mi recomendación es no mantener esa ruta. En lugar de desactivar el thinking, bájalo a effort medium con thinking activado:

    // Sustituto recomendado para las rutas que antes desactivaban thinking
    thinking: { type: "adaptive" },
    output_config: { effort: "medium" },
    

    En Opus 5 los niveles low y medium rinden inusualmente bien. La intuición de "menos effort, peor respuesta" que traías de la generación anterior ya no aplica igual: prueba medium antes de asumir que necesitas high.

    Con un matiz, para que nadie me lea en diagonal: para coding y trabajo agéntico, Anthropic recomienda arrancar en xhigh y bajar solo donde tus evals demuestren que la calidad aguanta. Lo de medium es el sustituto de las rutas que antes desactivaban el thinking, no un consejo para bajarle el effort a tu agente de coding. Y si al bajarlo compruebas que la tarea nunca necesitó Opus, Claude Sonnet 5 cubre buena parte de ese terreno por bastante menos dinero.


    El tercer sitio donde revienta: el rechazo que llega con un 200

    Este no está en la lista oficial de breaking changes, pero te va a tirar producción igual.

    Los clasificadores de seguridad pueden declinar una petición. Cuando lo hacen, la API devuelve HTTP 200 con stop_reason: "refusal". No es un error. Tu try/catch no lo captura, tu retry no se dispara, tu monitorización no lo ve.

    Y content llega vacío: un array sin bloques. Así que este patrón —el que escribe todo el mundo la primera vez— revienta con un TypeError:

    const res = await client.messages.create({ /* ... */ });
    const text = res.content[0].text; // 💥 TypeError: content llega vacío
    

    La corrección son cuatro líneas:

    const res = await client.messages.create({ /* ... */ });
    
    if (res.stop_reason === "refusal") {
      logger.warn("Petición declinada por los clasificadores", { requestId: res.id });
      return fallbackResponse();
    }
    
    const text = res.content.find((b) => b.type === "text")?.text ?? "";
    

    Dos datos más que ayudan aquí. Un rechazo que llega antes de emitir output no se factura, aunque sí consume rate limit. Y si no quieres montar el fallback a mano, Anthropic tiene un parámetro fallbacks en modo "default" (con el beta header server-side-fallback-2026-07-01) que reencamina la petición rechazada a otro modelo dentro de la misma llamada: los rechazos de categoría ciber caen a Opus 4.8.

    Comprueba stop_reason antes de leer content. Siempre. Con este modelo y con el siguiente.


    Dos cambios de comportamiento que te van a sorprender

    Escribe respuestas más largas por defecto. Y bajar el effort no lo arregla —es un eje distinto—. Si necesitas respuestas breves, pídelo en el prompt de forma explícita: límite de palabras, formato, o ambos.

    Verifica su propio trabajo sin que se lo pidas. Esta es la importante, porque invierte una buena práctica de prompting que era válida hasta la semana pasada.

    Todos tenemos system prompts con alguna variante de "revisa tu respuesta antes de contestar". En Opus 5 esas instrucciones provocan verificación excesiva: más tokens, más latencia, misma calidad. La solución no es reescribirlas con mejor redacción. Es borrarlas.

    Con la delegación en subagentes el ajuste es distinto. Opus 5 delega más que Opus 4.8 por defecto, así que los empujones que añadiste para forzarla ahora sobran. Pero aquí no basta con borrar: la recomendación de Anthropic es poner límites —en qué escenarios se delega, o cuántos subagentes como máximo—. Pasas de empujar a acotar.

    Es la parte contraintuitiva del oficio: mantener un system prompt no es acumular reglas, es borrarlas cuando el modelo ya no las necesita. Es exactamente el criterio que trabajo en el curso Construye con IA, donde el prompt se trata como código con mantenimiento, no como un texto que se escribe una vez y se olvida.


    Lo que mejora en Claude Opus 5 sin que toques nada

    El mínimo de prompt caching baja a 512 tokens. En Opus 4.8 eran 1024, y el umbral está en la documentación de prompt caching. Prompts de sistema que antes se quedaban justo por debajo del umbral y no cacheaban, ahora sí cachean, sin cambiar una línea de código.

    Si tienes muchas llamadas cortas y repetitivas, revisa la factura la semana que viene: puede bajar sola. Y si aún no tienes el caching bien montado, la mecánica completa está en Prompt Caching en Claude: reduce tu factura de API un 90%.

    Otros dos datos que conviene tener a mano: la ventana de contexto es de 1M tokens —es a la vez el valor por defecto y el máximo— con 128K tokens de salida. Y los rate limits de Opus 5 son un cubo separado del pool combinado de Opus 4.x: al migrar tráfico no heredas tu cuota anterior. Si mueves un volumen serio, comprueba límites antes del despliegue y no el lunes por la mañana con todo el tráfico encima.


    Cómo migrar a Claude Opus 5 en 4 pasos

    Cuatro pasos, en este orden:

    1. Grep por "disabled" en todas tus llamadas al thinking. Cada resultado, con su effort al lado. Si hay xhigh o max, es un 400 esperándote.
    2. Revisa tus max_tokens. Todo lo que esté ajustado al límite de la respuesta necesita margen para el thinking, o cambia a thinking: { type: "disabled" } con effort high como máximo.
    3. Comprueba stop_reason antes de leer content. Cuatro líneas.
    4. Borra las instrucciones de auto-verificación de tus system prompts. No las reescribas. Y en las de delegación, cambia el empujón por un límite: cuándo se delega y cuántos subagentes como máximo.

    Media hora de trabajo. Y a cambio: te acercas a la inteligencia frontera de Fable 5 por la mitad de coste por tarea.

    Si quieres ver este tipo de migraciones aplicadas sobre proyectos reales —con los prompts, el código y los errores que salen por el camino— es lo que hacemos en Dominicode Labs, y voy publicando los análisis modelo a modelo en el canal de YouTube.

    El precio lo pone Anthropic. Los 400 los pones tú.


    Preguntas frecuentes sobre Claude Opus 5

    ¿Cuánto cuesta Claude Opus 5?

    $5 por millón de tokens de entrada y $25 por millón de tokens de salida, exactamente el mismo precio que tenía Opus 4.8. Fable 5 cuesta $10/$50, así que Opus 5 se acerca a esa franja de inteligencia frontera por la mitad. Existe un fast mode disponible únicamente en la API de Anthropic que cuesta el doble: $10/$50. En suscripciones, es el modelo por defecto de Claude Max y el más potente disponible en Claude Pro.

    ¿Qué se rompe al migrar de Opus 4.8 a Claude Opus 5?

    Dos cosas concretas. Primera: el parámetro thinking ahora viene activado por defecto, y como max_tokens es un tope duro sobre thinking más respuesta juntos, los presupuestos ajustados provocan respuestas cortadas a mitad de frase. Segunda: thinking: { type: "disabled" } combinado con effort xhigh o max devuelve un error 400, cuando en Opus 4.8 esa combinación era válida. A eso conviene sumar una tercera comprobación: los rechazos de los clasificadores llegan como HTTP 200 con stop_reason: "refusal", no como error.

    ¿Cómo desactivo el thinking en Claude Opus 5?

    Con thinking: { type: "disabled" }, pero solo puedes hacerlo hasta effort high. Si envías esa configuración con xhigh o max, la petición se rechaza con un 400. Y ojo: la validación se hace petición a petición, así que una llamada posterior que suba el effort se rechazará aunque todas las anteriores hayan funcionado. En la mayoría de casos compensa más bajar a effort medium con el thinking activado, porque en Opus 5 los niveles low y medium rinden bastante mejor de lo que esperarías.

    ¿Sigo necesitando el «revisa tu respuesta antes de contestar» en mis prompts?

    No, y además es contraproducente. Opus 5 verifica su propio trabajo sin que se lo pidas, así que esas instrucciones provocan verificación excesiva: gastas más tokens y añades latencia sin ganar calidad. La recomendación es borrarlas, no reescribirlas. Con la delegación en subagentes el ajuste es distinto: los empujones para forzarla sobran, pero Anthropic recomienda sustituirlos por un límite explícito —en qué escenarios se delega y cuántos subagentes como máximo—, porque Opus 5 delega más que Opus 4.8 por defecto.

    ¿Hay que cambiar algo para aprovechar el prompt caching en Opus 5?

    Nada. El mínimo de tokens necesario para cachear baja de 1024 a 512, así que los prompts que antes eran demasiado cortos para entrar en caché ahora cachean automáticamente, sin tocar código. Si tu carga de trabajo son muchas llamadas cortas con un system prompt repetido, es probable que la factura baje sola tras migrar.

    ¿Puedo mover todo mi tráfico de Opus 4.x a Opus 5 de golpe?

    Técnicamente sí, pero revisa los rate limits antes. Los límites de Opus 5 son un cubo separado del pool combinado de Opus 4.x, de modo que al migrar no heredas la cuota que ya tenías asignada. Si mueves un volumen alto sin comprobarlo, puedes empezar a recibir throttling con un código que hasta ese momento no lo veía nunca.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • happy-dom o jsdom: qué entorno DOM elegir en tests unitarios

    happy-dom o jsdom: qué entorno DOM elegir en tests unitarios

    Cambié a happy-dom la mitad de la suite que toca el DOM, un martes por la mañana. Era la última pieza de un cambio que dejó el job de CI en 6m 15s, desde los doce minutos que tardaba esa misma mañana. Me sentí muy listo.

    Dos semanas después, un componente de lazy loading llegó roto a producción. El test seguía en verde. Lo ejecuté cincuenta veces y cincuenta veces me dijo que todo estaba bien.

    El problema no era el test. Era el suelo sobre el que corría. Elegir entre happy-dom o jsdom no es una micro-optimización de CI: es decidir qué mentiras está autorizada a contarte tu suite.

    Con jsdom, ResizeObserver no existe. El test revienta con un error escandaloso, instalas un mock, el mock dispara el callback y el test comprueba algo de verdad. Con happy-dom, ResizeObserver sí existe: es una clase que se instancia sin quejarse y cuyos tres métodos están vacíos por dentro. El callback no se llama jamás.

    Mi setup tenía una guarda del tipo if (typeof window.ResizeObserver === 'undefined') para instalar el mock. Con happy-dom esa condición no se cumplía nunca. El mock no se instalaba. El test verificaba el vacío.


    Resumen rápido

    • En tests unitarios, el runner (Vitest, Jest) no es el entorno. El entorno es la librería que emula el navegador debajo: jsdom, happy-dom o ninguna.
    • Vitest arranca por defecto en node, sin DOM. Jest también. El DOM siempre lo pides tú.
    • En mi benchmark, happy-dom resultó 1,77x más rápido que jsdom (mediana de 5 pares A/B, rango 1,45x–1,96x), no las 5x-10x que circulan por ahí.
    • Ninguno de los dos tiene motor de layout. getBoundingClientRect() devuelve ceros en ambos. Cambiar de entorno no arregla eso.
    • happy-dom cubre más superficie de API moderna que jsdom (matchMedia, showModal, scrollIntoView), pero incluye stubs mudos que fingen existir.
    • Regla: node por defecto, happy-dom para tests de componente, jsdom fichero a fichero cuando algo se rompa. Se mezclan en el mismo proyecto.

    El runner no es el entorno

    Esta confusión cuesta tardes enteras.

    Cuando escribes environment: 'jsdom', Vitest instancia un window completo por cada fichero de test y lo inyecta en el contexto global antes de importar tu código. El runner orquesta. El entorno es quien finge ser un navegador.

    Y por defecto no hay ninguno: Vitest arranca en node por defecto, sin document ni window. Jest hace lo mismo desde la versión 27, y desde la 28 ni siquiera trae jsdom — hay que instalar jest-environment-jsdom a mano.

    Angular es el caso más traicionero. Desde que Vitest se convirtió en el test runner por defecto en Angular 21, el builder @angular/build:unit-test detecta qué tienes instalado: si encuentra happy-dom lo usa, y si no, cae a jsdom. Basta con que alguien añada happy-dom al package.json por cualquier motivo para que toda tu suite cambie de suelo sin que nadie toque un fichero de configuración.

    Si vienes de Karma, ese cambio de suelo es la parte que menos se cuenta y más duele. Lo desarrollé en Vitest en Angular 22: por qué Karma ya no es el default.


    Qué es jsdom y qué es happy-dom, sin marketing

    jsdom es la implementación de referencia. Se publicó por primera vez en noviembre de 2011: casi quince años de historia y la base sobre la que se ha testeado medio ecosistema JavaScript. Su norma es la fidelidad a la especificación: si algo está implementado, se comporta como en el navegador; si no puede implementarlo bien, prefiere no implementarlo. Su documentación deja layout y navegación explícitamente fuera de alcance. Versión actual: 29.1.1, del 30 de abril de 2026. Nada nuevo desde entonces.

    happy-dom es un emulador con otra prioridad: arrancar rápido y cubrir lo que los frameworks modernos usan de verdad. Versión 20.11.1, del 22 de julio de 2026, con nueve versiones publicadas entre el 3 de junio y esa fecha.

    En disco: jsdom instala 25 MB y 21 dependencias directas; happy-dom, 19 MB y 7. La diferencia real es mucho menor de lo que sugieren las comparativas que verás por ahí.


    Benchmark happy-dom vs jsdom: lo medí en vez de citarlo

    Circulan cifras de "5x-10x más rápido" que nadie respalda. Monté la prueba, y publico los datos crudos para que puedas comprobar cada división.

    Metodología — benchmark ejecutado por Bezael Pérez (Dominicode) el 24 de julio de 2026:

    Carga 50 ficheros × 3 tests con DOM real: listas de 100 nodos, eventos con dispatchEvent, 200 mutaciones de clases y atributos
    Protocolo 5 pares de ejecuciones alternando A/B (jsdom, happy-dom, jsdom, happy-dom…) para anular la deriva de carga de la máquina
    CPU Intel i7-11700K, 16 hilos, 32 GB RAM, Windows 11
    Runtime Node 24.16.0
    Runner Vitest 4.1.10
    Entornos jsdom 29.1.1 · happy-dom 20.11.1

    Datos crudos. Tiempo total de suite, par a par:

    Par jsdom happy-dom Ventaja
    1 26,47 s 14,93 s 1,77x
    2 21,94 s 15,11 s 1,45x
    3 26,90 s 15,77 s 1,71x
    4 15,70 s 8,56 s 1,83x
    5 16,27 s 8,31 s 1,96x

    La ventaja de happy-dom es 1,77x, la mediana de esos cinco ratios.

    Ojo con un detalle que despista, porque yo mismo tropecé con él: la mediana de los tiempos de jsdom (21,94 s) dividida entre la mediana de los de happy-dom (14,93 s) da 1,47x. Pero esas dos medianas salen de pares distintos —la primera del par 2, la segunda del par 1— y dividirlas mezcla ejecuciones que no compartieron condiciones de máquina. En un diseño A/B emparejado, el estimador correcto es el ratio dentro de cada par, y su mediana es 1,77x.

    Con ese mismo criterio, el resto de métricas:

    Métrica jsdom (mediana) happy-dom (mediana) Ventaja por par
    Tiempo total de suite 21,94 s 14,93 s 1,77x
    Ejecución pura de los tests 4,29 s 1,74 s 2,5x
    Arranque del entorno (acumulado) 235,6 s 119,0 s 2,2x

    Y ahora el dato que de verdad cambia decisiones. Segunda suite, 50 ficheros con un único expect(1 + 1).toBe(2), que aísla el coste de levantar el entorno:

    Entorno Tiempo total Arranque por fichero
    node 1,55 s ~0,2 ms
    happy-dom 4,20 s ~0,75 s
    jsdom 7,71 s ~1,55 s

    Las dos tablas no miden lo mismo y no debes cruzarlas: el arranque acumulado de la primera incluye montar y desmontar un documento con cientos de nodos por fichero, con los 16 hilos saturados; la segunda mide levantar un DOM vacío. Compara cada tabla consigo misma.

    Dicho eso, léelo dos veces. Pasar de jsdom a happy-dom te da 1,8x. Pasar de jsdom a ningún DOM te da 5x.

    La optimización más rentable de tu suite no es cambiar de emulador. Es dejar de cargar un emulador en los tests que no tocan el DOM: reducers, servicios, validadores, utilidades puras. Esos no necesitan window, y probablemente son el 70% de tu suite.


    Dónde te rompe cada uno: qué APIs faltan en jsdom y en happy-dom

    Ejecuté el mismo fichero de sondeo en los dos entornos. Esto es lo que devolvió, no lo que dice la documentación:

    API jsdom 29.1.1 happy-dom 20.11.1
    getBoundingClientRect() todo a 0 todo a 0
    offsetWidth / offsetTop 0 0
    getComputedStyle() con estilos inline correcto correcto
    window.matchMedia no existe
    Element.scrollIntoView no existe
    document.elementFromPoint no existe
    dialog.showModal() no existe
    CSS.supports no existe
    navigator.clipboard no existe
    ResizeObserver no existe stub que nunca dispara
    IntersectionObserver no existe stub que nunca dispara
    canvas.getContext('2d') null, o real con el paquete canvas null, sin alternativa
    Element.animate (WAAPI) no existe no existe
    Custom elements y Shadow DOM

    Un matiz sobre dialog: jsdom sí define el constructor HTMLDialogElement, pero showModal, show y close no están en el prototipo. No es que lancen una excepción propia: es que 'showModal' in dialog devuelve false.

    Tres conclusiones incómodas.

    Una: el relato de "jsdom es más completo" es falso tal y como se cuenta. En superficie de API moderna gana happy-dom. jsdom sigue sin matchMedia en 2026, probablemente el mock más copiado y pegado de la historia del frontend.

    Dos: ninguno tiene layout. Si tu test necesita que getBoundingClientRect() devuelva algo distinto de cero, cambiar de entorno no te salva.

    Tres, la que me costó el susto: happy-dom prefiere un stub silencioso a un fallo ruidoso. Este es su ResizeObserver real, tal cual está en el repositorio:

    export default class ResizeObserver {
      public observe(): void {
        // TODO: Not implemented
      }
      public unobserve(): void {
        // TODO: Not implemented
      }
      public disconnect(): void {
        // TODO: Not implemented
      }
    }
    

    IntersectionObserver sigue el mismo patrón: guarda el callback en el constructor y expone un takeRecords() que devuelve siempre un array vacío, pero observe() tiene el cuerpo igual de hueco. Tu código lo instancia, llama a observe(), no pasa nada y el test sigue adelante. Un fallo ruidoso cuesta diez minutos. Uno silencioso cuesta un incidente.

    En lo fundamental son gemelos: probé validación de formularios, sanitización de input[type=number], ciclo de vida de custom elements, <template>, orden de propagación capture/bubble, resolución de URLs relativas y parseo de HTML mal formado. Resultado idéntico en ambos. Para el 95% de los tests de componente da exactamente igual cuál uses.


    Cómo se configuran, y cómo se mezclan

    Lo que casi nadie cuenta: no tienes que elegir uno para todo el proyecto. Con Vitest 4 defines proyectos por glob.

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    
    export default defineConfig({
      test: {
        projects: [
          {
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.spec.ts'],
              exclude: ['src/**/*.component.spec.ts'],
            },
          },
          {
            test: {
              name: 'dom',
              environment: 'happy-dom',
              include: ['src/**/*.component.spec.ts'],
            },
          },
        ],
      },
    })
    

    Ese exclude no es decorativo. Sin él, src/**/*.spec.ts también captura los *.component.spec.ts, cada test de componente se ejecuta dos veces —una en node y otra en happy-dom— y la ejecución en node falla con un expected 'undefined' to be 'object' que parece un bug de tu componente y no lo es.

    Y cuando un fichero suelto necesite jsdom, lo declaras en la primera línea. Ese comentario gana a la configuración del proyecto:

    // @vitest-environment jsdom
    import { it, expect } from 'vitest'
    
    it('corre en jsdom aunque el proyecto use happy-dom', () => {
      expect(window.navigator.userAgent).toContain('jsdom')
    })
    

    Lo he verificado ejecutándolo: ese fichero arranca en jsdom mientras el resto de la suite sigue en happy-dom. Un solo test lento no justifica frenar los otros mil.

    En Angular la palanca es distinta, porque el builder elige por ti según lo que esté instalado. Lo robusto es no depender de esa autodetección: apunta la opción runnerConfig del builder a un vitest.config.ts con environment fijado explícitamente, y así da igual lo que aparezca en el package.json. Si prefieres la vía rápida, deja instalado solo uno de los dos:

    # alternativa: fuerza jsdom eliminando la otra opción
    npm uninstall happy-dom && npm install -D jsdom
    

    Y añade esto a tu fichero de setup para que los observers dejen de mentirte:

    // test-setup.ts
    import { vi, beforeEach } from 'vitest'
    
    class ResizeObserverMock {
      constructor(private cb: (entries: unknown[], obs: unknown) => void) {}
      observe = vi.fn((target: Element) =>
        this.cb([{ target, contentRect: target.getBoundingClientRect() }], this))
      unobserve = vi.fn()
      disconnect = vi.fn()
    }
    
    class IntersectionObserverMock {
      constructor(private cb: (entries: unknown[], obs: unknown) => void) {}
      observe = vi.fn((target: Element) => this.cb([{ target, isIntersecting: true }], this))
      unobserve = vi.fn()
      disconnect = vi.fn()
      takeRecords = vi.fn(() => [])
    }
    
    beforeEach(() => {
      vi.stubGlobal('ResizeObserver', ResizeObserverMock)
      vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
    })
    

    Dos clases, no una. Un mock compartido que emite { isIntersecting: true } para ambos revienta en cuanto un componente responsive lee entries[0].contentRect.width, porque esa propiedad no existe en la entry: TypeError: Cannot read properties of undefined. Cada observer tiene su forma de entry y hay que respetarla.

    Y fíjate en el otro detalle: asigno siempre, sin comprobar antes si existe. Esa comprobación es exactamente lo que me llevó a producción con un test verde y un componente roto.


    La regla para elegir entre happy-dom o jsdom

    Cinco pasos, en este orden. Los aplico tal cual.

    1. node por defecto. Si el test no toca document, no cargues DOM. Ahí está el 5x, no en la comparativa de emuladores.
    2. happy-dom para tests de componente. Casi el doble de rápido y con más API moderna cubierta. Es la elección por defecto en 2026.
    3. Nunca uses guardas del tipo if (typeof window.X === 'function') en el setup. Sobrescribe siempre los observers con mocks que disparen.
    4. jsdom fichero a fichero, no suite entera. ¿Un test necesita canvas real o un comportamiento de spec que happy-dom aproxima mal? // @vitest-environment jsdom en la línea 1 y sigues.
    5. Si necesitas layout de verdad, ningún emulador sirve. Posiciones reales, scroll real, capturas visuales: eso es Browser Mode de Vitest, estable desde la 4.0, con Playwright debajo. Más lento, y el único sitio donde esos tests significan algo.

    La excepción que invierte los pasos 2 y 4: si mantienes una librería de componentes que consumen otros, empieza en jsdom. Ahí prefieres un fallo ruidoso a una aproximación cómoda, porque el coste de un falso verde no lo pagas tú.

    Razonar sobre el entorno antes que sobre el aserto es la columna vertebral del curso de Testing en Angular, donde monto la suite desde cero decidiendo qué corre en node, qué en DOM emulado y qué en navegador real. Y si lo que te falta es la base del framework antes de entrar a testearlo, esa parte la cubro en el curso de Angular Moderno.


    Lo que puedes hacer hoy

    Abre tu vitest.config.ts y mira qué environment tienes a nivel global para tus tests unitarios.

    Si es jsdom o happy-dom para toda la suite, acabas de encontrar tu mayor ganancia de tiempo del trimestre: sepáralo en dos proyectos y manda a node todo lo que no toque document. Diez minutos de trabajo.

    Después añade el mock de los observers al setup. Porque el test que más te va a costar en tu carrera no es el que falla: es el que pasa por el motivo equivocado.

    Si quieres seguir tirando del hilo, tengo publicado Testing en Angular con IA: tests que protegen de verdad, donde ataco el mismo problema desde el otro lado. Y si prefieres verlo montado sobre un proyecto real y con gente a la que preguntar, te espero en Dominicode Labs.


    Preguntas frecuentes sobre happy-dom y jsdom

    ¿Qué es más rápido, happy-dom o jsdom?

    happy-dom. En el benchmark que ejecuté en Dominicode en julio de 2026 con Vitest 4.1.10, sobre 50 ficheros con manipulación real de DOM y cinco pares de ejecuciones alternadas, happy-dom resultó 1,77x más rápido que jsdom en mediana, con un rango de 1,45x a 1,96x. En ejecución pura de operaciones DOM la ventaja sube a 2,5x y en arranque del entorno es de 2,2x. Las cifras de "5x o 10x" que circulan no se corresponden con lo que mide una suite real. La ganancia grande está en no cargar ningún DOM: el entorno node fue 5 veces más rápido que jsdom en la misma máquina.

    ¿Merece la pena migrar de jsdom a happy-dom?

    Depende de dónde esté tu cuello de botella, y casi nunca está donde crees. Si tu suite tarda diez minutos, migrar a happy-dom te deja en unos seis: real, pero no transformador. Antes de eso, mira cuántos de tus tests cargan un DOM sin necesitarlo, porque mover esos a environment: 'node' da una mejora del orden de 5x en esa parte de la suite y no tiene ningún riesgo de compatibilidad. Mi recomendación es hacerlo en ese orden: primero separa node de DOM, después cambia el emulador y, si algún fichero se rompe, pásalo a jsdom con el comentario // @vitest-environment jsdom en lugar de revertir la migración entera.

    ¿Cuál usa Vitest por defecto?

    Ninguno de los dos. El valor por defecto de test.environment en Vitest es node, sin window ni document. Para tener DOM debes instalar jsdom o happy-dom y declararlo en vitest.config.ts o con el comentario // @vitest-environment en la cabecera del fichero. Jest se comporta igual: su entorno por defecto es node y desde Jest 28 hay que instalar jest-environment-jsdom como paquete aparte.

    ¿Qué entorno DOM usa Angular con Vitest?

    El builder @angular/build:unit-test detecta automáticamente qué tienes instalado: prefiere happy-dom si está presente y cae a jsdom si no. Conviene saberlo porque implica que añadir happy-dom al package.json por cualquier motivo cambia el entorno de toda la suite sin que nadie modifique la configuración. Si quieres un comportamiento predecible, fija environment de forma explícita en el fichero de configuración al que apunta la opción runnerConfig del builder, en vez de confiar en la autodetección.

    ¿Por qué mi test falla con "ResizeObserver is not defined"?

    Porque estás en jsdom, que no implementa ResizeObserver ni IntersectionObserver: la propiedad no existe en window. La solución es añadir un mock en el fichero de setup, con una clase distinta para cada uno, porque sus entries tienen forma diferente: contentRect en el de resize e isIntersecting en el de intersection. Ojo con el matiz: en happy-dom esas clases sí existen, pero sus métodos están vacíos y el callback no se ejecuta nunca. Si tu mock está protegido por una comprobación de existencia, en happy-dom no se instalará y tu test pasará sin comprobar nada.

    ¿Puedo usar happy-dom y jsdom en el mismo proyecto?

    Sí, y es la mejor estrategia. Con Vitest 4 defines varios proyectos en test.projects, cada uno con su environment y su glob de ficheros. Cuida los globs: si un proyecto incluye src/**/*.spec.ts y otro src/**/*.component.spec.ts, los ficheros de componente caen en los dos y se ejecutan por duplicado, así que necesitas un exclude en el primero. Además, el comentario // @vitest-environment jsdom en la primera línea de un fichero tiene prioridad sobre la configuración del proyecto, así que puedes mantener toda la suite en happy-dom y mover a jsdom solo los ficheros que lo necesiten. No hay que migrar en bloque.

    ¿Con cuál funciona getBoundingClientRect?

    Con ninguno. Ni jsdom ni happy-dom incorporan motor de layout, así que getBoundingClientRect(), offsetWidth y offsetTop devuelven cero en los dos. La documentación de jsdom lo declara explícitamente fuera de alcance. Si tu test depende de posiciones o tamaños reales, la única salida es un navegador de verdad: Browser Mode de Vitest, estable desde la 4.0, con Playwright por debajo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • IA generativa vs IA agéntica: la diferencia que decide tu stack

    IA generativa vs IA agéntica: la diferencia que decide tu stack

    Hace unas semanas un CTO me escribió para que le ayudara a "medir el retorno de la IA" en su equipo. Doce developers, doce licencias, una factura mensual que ya se notaba en la hoja de gastos.

    Le pregunté qué hacían exactamente con ellas.

    "Autocompletar. Y a veces le preguntan cosas al chat."

    Ahí estaba todo. Ese equipo no tenía un problema de retorno: tenía un problema de categoría. Estaban pagando IA generativa y esperando resultados de IA agéntica.

    Y la diferencia entre IA generativa vs IA agéntica no es una discusión de nomenclatura para ponentes de conferencia. Es la decisión que determina qué compras, cuánto pagas cada mes y qué trabajo puedes delegar de verdad.

    En 2026, seguir describiendo el trabajo de un programador con IA como "IA generativa" es señal de llevar dos años de retraso.


    Resumen rápido

    • IA generativa es un sistema que produce un artefacto (texto, código, JSON) a partir de un prompt y termina ahí: no ejecuta nada, no verifica nada, no conserva estado.
    • IA agéntica es un sistema que recibe un objetivo en lugar de un prompt, tiene acceso a herramientas y repite el ciclo observar → decidir → actuar → verificar hasta cumplir un criterio de parada.
    • El modelo puede ser el mismo. Lo que cambia es la capa que lo envuelve: herramientas, permisos y criterio de parada.
    • Regla de decisión: si existe una señal automática de verdad (tests, typecheck, build, lint) que diga si el trabajo está bien hecho, es territorio de agente. Si el único verificador eres tú leyendo, es territorio de prompt.
    • Coste: un agente consume unas 4 veces más tokens que un chat; un sistema multi-agente, unas 15 (Anthropic, junio 2025).

    Lo que compraste no era generativa: era autocompletado caro

    El equipo de ese CTO usaba la IA exactamente igual que en 2023. Escribir media línea, aceptar la sugerencia gris. Abrir un chat, pegar un stack trace, copiar la respuesta de vuelta al editor.

    Eso funciona. Ahorra minutos. Pero el trabajo sigue siendo tuyo: tú lees el repo, tú decides, tú ejecutas, tú verificas si la respuesta era correcta, tú vuelves a preguntar cuando no lo era.

    La IA hace la parte fácil —escribir texto plausible— y tú te quedas con el bucle completo. Con doce licencias, lo que compras es doce veces la parte fácil.

    El salto de productividad real no está en generar mejor código. Está en dejar de ser tú quien cierra el bucle.


    ¿Qué es la IA generativa? Tú pides, ella escupe

    La IA generativa es un sistema que produce un artefacto a partir de un prompt y termina ahí. Entra un prompt, sale texto, código, un JSON, un diagrama. Se acabó.

    No tiene estado. No sabe qué hay en tu repositorio salvo lo que le pegas. No sabe si su respuesta compiló. No sabe si el test pasó. No puede saberlo, porque no ejecuta nada.

    Eso no es un defecto. Es el diseño. Y para tareas de un solo salto es imbatible: latencia de segundos, coste de céntimos, resultado inmediato.

    Un regex complejo. El mensaje de un commit a partir de un diff. Explicar qué demonios hace esa función de 2019 que nadie toca. Convertir un objeto de ejemplo en una interfaz de TypeScript.

    En todos esos casos, montar un agente es como contratar una mudanza para llevar una caja de zapatos al piso de arriba.


    ¿Qué es la IA agéntica? Lee, decide, ejecuta y vuelve

    La IA agéntica es un sistema que recibe un objetivo en lugar de un prompt, tiene acceso a herramientas y permiso para usarlas. Aquí el contrato cambia por completo.

    El agente lee ficheros. Ejecuta comandos. Mira la salida. Decide el siguiente paso a partir de lo que ha visto, no de lo que tú le contaste. Y repite hasta que se cumple un criterio de parada.

    Ese mecanismo tiene nombre y lo desmonté pieza a pieza en Agentic loop: el mecanismo detrás de los agentes de IA.

    Ese ciclo —observar, decidir, actuar, verificar, repetir— es todo el asunto. Lo he desarrollado a fondo en Loop Engineering: la evolución definitiva del desarrollo con IA, porque diseñar bien ese bucle es hoy más determinante que elegir modelo.

    Fíjate en que el modelo puede ser exactamente el mismo. Claude generando texto en una web y Claude arreglando un test en tu CI son el mismo peso de red neuronal. Lo que cambia es lo que hay alrededor: las herramientas que le das, los permisos que aceptas, la señal que le dice si ha terminado.

    Un LLM solo no es un agente, igual que un motor no es un coche. Esa capa que lo envuelve —el harness— es la que hace el trabajo, y lo expliqué en detalle en El Agentic Harness: por qué un LLM por sí solo no es un producto.

    La prueba práctica para distinguirlas: pídele algo cuyo resultado no puedas predecir sin ejecutar código. "Arregla el test que falla en CI" no lo resuelve un chat, por bueno que sea el modelo. Requiere leer el log, formular una hipótesis, tocar el código y volver a ejecutar. Eso es un agente o no es nada.

    Si vienes de cero con esto, la guía definitiva de Agentes de IA cubre los fundamentos y lo dejas para después de este.


    IA generativa o IA agéntica: cuál usar en cada tarea

    Esta es la asignación que uso a diario: a la izquierda la tarea, a la derecha la categoría que la resuelve con el menor coste y la menor latencia.

    Tarea Generativa o agéntica Por qué
    Escribir un regex o una query SQL puntual Generativa Salida única, la verificas tú en cinco segundos
    Redactar el mensaje de un commit Generativa El contexto está en el diff, no hay bucle que cerrar
    Explicar una función o un fichero legacy que no entiendes Generativa Necesitas comprensión, no cambios en disco
    Renombrar un concepto de dominio en 40 archivos Agéntica Hay que leer el repo, decidir caso a caso y comprobar que compila — no es el rename del IDE: cambian nombres, strings, rutas y documentación
    Arreglar un test en rojo que puedes reproducir en local Agéntica Requiere hipótesis, ejecución y reintento con la salida real
    Migrar un módulo de RxJS a Signals Agéntica Cambios encadenados con verificación continua vía tests
    Generar mocks o datos de ejemplo Generativa Un salto, coste mínimo, no toca disco
    Subir una dependencia mayor con breaking changes Agéntica El error aparece al ejecutar, no al leer
    Escribir la primera versión de un componente aislado Generativa Lo revisas tú de un vistazo; el bucle no aporta

    El patrón se ve solo: si existe una señal automática de verdad —tests, typecheck, build, lint— que diga si el trabajo está bien hecho, es territorio de agente. Si el único verificador eres tú leyendo, es territorio de prompt.


    El error caro: meter un agente donde bastaba un prompt

    Este es el fallo que más veo desde que los agentes se pusieron de moda. Y sale caro en cuatro dimensiones.

    Coste. Un agente no hace una llamada al modelo: hace decenas. Anthropic publicó las cifras de su sistema de investigación multi-agente en junio de 2025: los agentes consumen unas 4 veces más tokens que una interacción de chat, y los sistemas multi-agente unas 15 veces más. Está medido en tareas de investigación, pero el orden de magnitud se traslada. Cuando delegas a un agente algo que resolvía un prompt, estás multiplicando la factura por un trabajo idéntico.

    Latencia. El chat te responde en segundos. El agente tarda minutos porque lee, ejecuta, falla, reintenta. Para una tarea de treinta segundos, esa espera es una pérdida neta de tiempo, no una ganancia.

    No determinismo. Con un prompt, si la respuesta no te gusta, la descartas y ya está. Con un agente, cada ejecución toma un camino distinto: puede tocar archivos que no esperabas, reescribir un test en lugar de arreglar el código o "resolver" el fallo borrando la aserción que molestaba. Dos ejecuciones del mismo objetivo rara vez producen el mismo diff.

    Superficie de fallo. Un chat solo puede equivocarse en el texto. Un agente con permisos de escritura y shell puede equivocarse en tu disco, en tu historial de git y en tu base de datos de desarrollo. Cada herramienta que le das es potencia y es riesgo, en la misma proporción.

    Resumido en una tabla:

    Dimensión Prompt (generativa) Agente (agéntica)
    Coste Una llamada al modelo Decenas de llamadas: ~4x tokens, ~15x si es multi-agente
    Latencia Segundos Minutos: lee, ejecuta, falla, reintenta
    Determinismo Descartas la respuesta y repites Cada ejecución toma un camino distinto
    Superficie de fallo El texto que devuelve Tu disco, tu historial de git, tu base de datos de desarrollo

    Mi regla, sin matices: si puedes verificar el resultado leyéndolo en menos de un minuto, no necesitas un agente.


    Cómo migrar tu flujo de una a otra en 3 pasos

    Si hoy vives en el chat y quieres pasar al bucle, no empieces instalando frameworks. Empieza por aquí.

    1. Cierra el bucle de verificación antes de dar un solo permiso

    Un agente sin forma de comprobar su propio trabajo es un generador de texto con acceso a tu disco. Es la peor combinación posible.

    Antes de delegar nada, asegúrate de que existe un comando que responde sí o no:

    # El criterio de parada del agente: un comando que responde sí o no
    npm test && npx tsc --noEmit && npm run lint
    

    Ese comando es el criterio de parada. Si tu proyecto no tiene tests que corran rápido y en verde, tu primer trabajo agéntico es conseguirlos —y si trabajas con Angular y andas flojo ahí, el curso de Testing en Angular con Jest y Testing Library resuelve justo esa base.

    Sin señal de verdad no hay agente. Hay ruleta.

    2. Escribe la especificación, no el prompt

    Un prompt describe una petición. Una especificación describe un resultado esperado, sus límites y qué queda fuera del alcance.

    La diferencia importa porque el agente va a tomar cientos de microdecisiones que tú no vas a supervisar. Todo lo que no esté escrito lo va a inventar.

    Es la metodología que uso a diario y la que documenté entera en el libro de Spec-Driven Development: especificar primero, delegar después. Cambia el resultado más que cambiar de modelo.

    3. Empieza por una tarea aburrida, acotada y reversible

    Nada de "refactoriza la arquitectura". Elige algo que te lleve entre veinte y cuarenta minutos a mano, con criterio de éxito objetivo y sobre una rama nueva.

    Migrar un módulo. Añadir tests a un servicio. Actualizar una dependencia. Ejecuta, revisa el diff completo, mide cuánto ha tardado y cuánto has tenido que corregir.

    Si quieres ver ese primer trabajo delegado en la terminal, lo hago paso a paso con Claude Code.

    Sube el listón solo cuando esa tarea salga limpia dos veces seguidas. Y cuando llegue el momento de elegir herramientas de verdad, tengo mi criterio completo en Stack IA agéntica en 2026: qué usar, qué ignorar y cuál elijo.


    La pregunta que resuelve el 90% de las decisiones

    No memorices la tabla. Quédate con una sola pregunta antes de abrir cualquier herramienta:

    ¿Existe una señal automática que diga si el trabajo está bien hecho?

    Si existe, delega el bucle: es trabajo de agente. Si no existe, el bucle eres tú, y lo que necesitas es un buen prompt y tus ojos encima.

    Esa pregunta te ahorra factura y sustos.

    Si quieres ver el bucle completo montado de principio a fin —del objetivo a un producto funcionando, con especificaciones, herramientas y verificación— es exactamente lo que construimos en el curso Construye con IA. Y si prefieres hacerlo acompañado, con proyectos reales y gente que ya está en esto, te espero en Dominicode Labs.


    Preguntas frecuentes

    ¿Cuál es la diferencia entre IA generativa e IA agéntica?

    La IA generativa produce un artefacto a partir de un prompt y ahí termina: no ejecuta código, no verifica su propia salida y no conserva estado entre peticiones. La IA agéntica recibe un objetivo, dispone de herramientas para leer ficheros y ejecutar comandos, y repite el ciclo observar, decidir, actuar y verificar hasta cumplir un criterio de parada. El modelo subyacente puede ser el mismo en ambos casos; lo que cambia es la capa que lo envuelve. La prueba práctica para distinguirlas es pedir algo cuyo resultado no puedas predecir sin ejecutar código: eso solo lo resuelve un agente.

    ¿La IA agéntica sustituye a la IA generativa?

    No. La agéntica se construye encima de la generativa: el modelo que razona dentro del agente es el mismo tipo de modelo que responde en un chat. Lo que cambia es la capa que lo envuelve, con herramientas, permisos y un criterio de parada. En un flujo de trabajo real conviven las dos, y la mayoría de tus interacciones diarias seguirán siendo generativas porque son más rápidas y más baratas.

    ¿Cuánto más caro sale usar un agente en vez de un chat?

    Entre 4 y 15 veces más en consumo de tokens. Según los datos publicados por Anthropic en junio de 2025, un agente consume alrededor de 4 veces más tokens que una interacción de chat, y un sistema multi-agente unas 15 veces más. La cifra exacta depende del modelo y de la tarea, pero el orden de magnitud es ese: el agente solo compensa cuando la tarea es suficientemente valiosa como para justificar el gasto.

    ¿Un chat con acceso a herramientas ya es un agente?

    Solo si cierra el bucle. Ejecutar una búsqueda web y devolverte el resultado sigue siendo un salto único. Un agente encadena decisiones: usa la salida de una herramienta para elegir la siguiente acción, evalúa si ha cumplido el objetivo y reintenta cuando no. Si el sistema no puede reintentar por su cuenta a partir de lo que ha observado, es un chat con extras.

    ¿Necesito un framework de agentes para empezar?

    No al principio. Los asistentes de terminal actuales — Claude Code, Codex CLI, Gemini CLI — ya traen el bucle implementado, y con eso cubres la mayoría de tareas de desarrollo diario. El framework empieza a tener sentido cuando construyes un agente propio para un producto: cuando necesitas orquestar varios pasos, persistir estado entre ejecuciones o exponer herramientas específicas de tu dominio.

    ¿Qué tareas no delegaría hoy a un agente?

    Tres tipos. Las que no tienen verificación automática, porque no hay forma de saber si acertó sin revisarlo todo a mano. Las irreversibles: migraciones sobre datos de producción, borrados, despliegues sin rollback. Y las decisiones de arquitectura, porque un agente optimiza para que el criterio de parada se cumpla, no para que el sistema siga siendo mantenible dentro de dos años. Esa parte todavía es tuya.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Nuxt 4: la estructura app/ que rompe los tutoriales viejos

    Nuxt 4: la estructura app/ que rompe los tutoriales viejos

    Nuxt 3 recibe parches —bug fixes y seguridad— hasta el 31 de julio de 2026. A partir de esa fecha, ninguno. Lo dice la roadmap oficial de Nuxt.

    Si lees esto en julio de 2026, te quedan días. Si lo lees después, la fecha ya pasó y tu proyecto en Nuxt 3 corre sin soporte oficial.

    Y aquí viene lo divertido: si hoy buscas "tutorial Nuxt 4" vas a encontrar decenas de artículos escritos en julio de 2025, la semana del lanzamiento. Te van a decir que crees una carpeta pages/ en la raíz del proyecto. Vas a hacerlo, no va a funcionar, y vas a perder cuarenta minutos pensando que te has equivocado en la instalación.

    No te has equivocado. El tutorial está desfasado. Nuxt 4 movió esa carpeta de sitio y ese es exactamente el cambio del que casi nadie ha actualizado su contenido.

    Este post está escrito contra Nuxt 4.5.0, la versión estable en julio de 2026. No contra la 4.0 del día del lanzamiento, que es lo que describe casi todo el material que vas a encontrar.

    ¿Qué es Nuxt 4?

    Nuxt 4 es el meta-framework de Vue: coge Vue, que por sí solo es una librería de UI, y le añade routing por sistema de ficheros, renderizado en servidor, un servidor HTTP propio (Nitro), generación estática, auto-imports y un sistema de módulos. La versión 4 se publicó el 16 de julio de 2025 e introdujo el cambio más visible respecto a Nuxt 3: el código de aplicación se movió a la carpeta app/.

    Si vienes de Angular o de React, la analogía más limpia es esta: Nuxt es a Vue lo que Next.js es a React, o lo que Analog aspira a ser en Angular. Un meta-framework — todo lo que necesitas para llevar una aplicación a producción, ya montado.

    La tesis de Nuxt es que tú no deberías estar configurando nada de eso. Creas un fichero en el sitio correcto y el framework infiere la intención. Esto encanta o irrita, según de dónde vengas. Si llegas de Angular, donde todo es explícito y declarado, la cantidad de magia implícita te va a chirriar los primeros días. Es una decisión de diseño consciente, no un descuido.

    Estructura de directorios en Nuxt 4: la carpeta app/

    En Nuxt 4 el código de aplicación vive dentro de app/. Las carpetas pages/, components/, composables/, layouts/, middleware/, plugins/ y utils/, junto a app.vue, app.config.ts y error.vue, van en app/ — no en la raíz del proyecto como en Nuxt 3. Las carpetas de servidor y recursos (server/, public/, content/, layers/, modules/ y shared/) se quedan en la raíz. Lo tienes en la documentación oficial.

    Si solo te llevas una cosa del post, que sea esta.

    En Nuxt 3 todo eso vivía junto en la raíz: pages/, components/ y composables/ mezclados con server/, public/ y los ficheros de configuración. La raíz ahora queda para lo que no es aplicación Vue.

    Así queda un proyecto:

    mi-proyecto/
    ├── app/
    │   ├── assets/
    │   ├── components/
    │   ├── composables/
    │   ├── layouts/
    │   ├── middleware/
    │   ├── pages/
    │   ├── plugins/
    │   ├── utils/
    │   ├── app.vue
    │   ├── app.config.ts
    │   └── error.vue
    ├── server/
    ├── public/
    ├── content/
    ├── layers/
    ├── modules/
    ├── shared/
    ├── nuxt.config.ts
    ├── package.json
    └── tsconfig.json
    

    Fíjate bien en la segunda mitad. server/, public/, content/, layers/, modules/ y shared/ no están dentro de app/. Se quedan en la raíz. Igual que node_modules/, .nuxt/ y .output/.

    El fallo número uno al seguir material antiguo es crear pages/ en la raíz. En Nuxt 4 va en app/pages/. El framework no la encuentra donde tú la has puesto y no pasa nada visible: simplemente no tienes rutas y no entiendes por qué.

    ¿Por qué el cambio? Porque tener todo el código en la raíz obligaba a los file watchers a escanear .git/ y node_modules/, algo que retrasa el arranque de forma notable en sistemas que no son macOS. Y la separación entre lo que corre en el navegador y lo que corre en el servidor por fin es visible a simple vista, además de darte mejores autocompletados en el IDE.

    Una nota sobre app/pages/: es opcional. Puedes construir una aplicación de una sola pantalla solo con app/app.vue y no crear la carpeta nunca. Pero en el momento en que quieras rutas y mantengas tu propio app.vue, necesitas poner <NuxtPage> dentro para que Nuxt tenga dónde renderizar la página actual. Si no lo pones, el enrutador funciona y no ves nada en pantalla.

    Crear el proyecto y arrancarlo

    Requisito previo: Node.js 22.x o superior. La documentación recomienda usar la LTS activa. Si tienes una 20 por ahí de otro proyecto, cámbiala antes de empezar o vas a pelearte con errores que no dicen lo que pasa.

    Para crear el proyecto:

    npm create nuxt@latest mi-proyecto
    

    Con pnpm:

    pnpm create nuxt@latest mi-proyecto
    

    Con bun:

    bun create nuxt@latest mi-proyecto
    

    Y para arrancar el servidor de desarrollo abriendo el navegador automáticamente:

    npm run dev -- -o
    

    Con pnpm es pnpm dev -o y con bun bun run dev -o. Ojo al doble guion en la versión de npm: sin él, npm se come el flag y no lo pasa a Nuxt.

    Lo que vas a usar todos los días

    Rutas por ficheros

    Cada fichero .vue dentro de app/pages/ genera una URL:

    app/pages/
      ├── index.vue       → /
      ├── about.vue       → /about
      └── posts/
          └── [id].vue    → /posts/:id
    

    Los corchetes marcan segmentos dinámicos. Dentro del componente, lees el parámetro con useRoute():

    <script setup lang="ts">
    const route = useRoute()
    console.log(route.params.id)
    </script>
    

    Para navegar entre páginas sin recargar, <NuxtLink>:

    <template>
      <NuxtLink to="/about">Sobre mí</NuxtLink>
      <NuxtLink to="/posts/1">Primer post</NuxtLink>
    </template>
    

    Nada de esto se importa. Los auto-imports de Nuxt te dan useRoute, NuxtLink y todo lo que haya en app/components/ sin una sola línea de import. Al principio desorienta. A la semana no quieres volver atrás.

    Data fetching: $fetch vs useFetch vs useAsyncData

    Aquí hay tres herramientas y elegir mal es el error más común de quien llega nuevo.

    Herramienta Cuándo usarla Segura en SSR Deduplica
    $fetch Acciones del usuario: click, envío de formulario No No
    useFetch Datos iniciales de un componente
    useAsyncData Lógica asíncrona que no es una simple llamada HTTP Sí, por clave

    $fetch es la utilidad básica de red. No tiene protección para SSR ni deduplicación de peticiones. Úsala para interacciones del cliente disparadas por un evento: un click, el envío de un formulario.

    useFetch es el envoltorio seguro para SSR. Hace la petición una sola vez en renderizado universal, en lugar de dispararla en servidor y otra vez en cliente al hidratar. Es lo que quieres para los datos iniciales de un componente:

    <script setup lang="ts">
    const { data, status, error, refresh } = await useFetch('/api/posts')
    </script>
    

    useAsyncData hace lo mismo pero con control más fino, y recibe una clave única como primer argumento para la caché. Es tu opción cuando la lógica asíncrona no es una simple llamada HTTP.

    Ambos devuelven data, error, status, y funciones refresh, execute y clear. Y aceptan opciones que conviene conocer desde el día uno: lazy para no bloquear la navegación, server: false para pedir solo en cliente, pick para recortar el payload que viaja al navegador y watch para refrescar cuando cambie un valor reactivo.

    Rutas de servidor

    Esto es lo que a mí me terminó de convencer de Nuxt: el backend vive en el mismo proyecto y no es un añadido de segunda.

    La carpeta server/ en la raíz se escanea sola:

    server/
    ├── api/          # rutas prefijadas con /api
    ├── routes/       # rutas sin prefijo
    ├── middleware/   # corre antes de cada handler
    ├── plugins/      # hooks del ciclo de vida de Nitro
    └── utils/        # helpers propios
    

    Un fichero server/api/hello.ts responde en /api/hello. Un fichero en server/routes/ responde sin el prefijo. Todos los handlers usan defineEventHandler:

    export default defineEventHandler((event) => {
      return { hello: 'world' }
    })
    

    Para leer datos de la petición tienes tres utilidades que también son globales:

    // server/api/hello/[name].ts
    export default defineEventHandler((event) => {
      const name = getRouterParam(event, 'name')
      return `Hola, ${name}`
    })
    
    // server/api/submit.post.ts
    export default defineEventHandler(async (event) => {
      const body = await readBody(event)
      return { body }
    })
    

    getQuery(event) te da los parámetros de query. Y el sufijo del nombre del fichero define el método HTTP: test.get.ts atiende los GET, test.post.ts los POST, y cualquier otro método devuelve un 405.

    Un aviso importante sobre esto. readBody te devuelve lo que venga, sin garantías de forma ni de tipo. En una ruta de servidor pública eso es una puerta abierta. Aquí es donde un schema de validación deja de ser buena práctica y pasa a ser obligatorio:

    import { z } from 'zod'
    
    const schema = z.object({
      email: z.string().email(),
      plan: z.enum(['free', 'pro']),
    })
    
    export default defineEventHandler(async (event) => {
      const result = schema.safeParse(await readBody(event))
    
      if (!result.success) {
        throw createError({ statusCode: 400, statusMessage: 'Payload inválido' })
      }
    
      // result.data viene tipado, no hace falta castear nada
      return { ok: true, email: result.data.email }
    })
    

    Validas el body antes de tocarlo y de paso obtienes el tipo inferido gratis. Si no tienes ese reflejo instalado, el curso de Zod cubre exactamente este patrón y se traslada tal cual a Nuxt.

    Novedades de Nuxt 4.5

    La 4.0 fue una versión de estabilidad. Nuxt 4.5 es donde está lo interesante.

    Vite 8. Este es el titular. Arranques en frío más rápidos e internals movidos a Rolldown. Si tienes plugins de Vite propios, revisa la guía de migración antes de actualizar, porque algo se te va a mover. Sobre lo que trae Vite 8 en sí escribí un análisis aparte, y aplica entero aquí: cuando actualizas Nuxt, estás actualizando también tu bundler.

    Rspack 2 sobre Rsbuild. El builder alternativo se moderniza. La configuración no cambia:

    export default defineNuxtConfig({
      builder: 'rspack',
    })
    

    SSR streaming, experimental. Envía el HTML por partes en lugar de esperar a tenerlo todo. Mejora bastante el Time to First Byte:

    export default defineNuxtConfig({
      experimental: {
        ssrStreaming: true,
      },
    })
    

    Códigos de error estables. Los errores y avisos ahora vienen con un identificador fijo tipo NUXT_E1001, con explicación en línea y enlace a la documentación. Suena menor. No lo es: convierte "esto peta y no sé por qué" en una búsqueda con un solo resultado correcto.

    useLayout. Un composable para leer el layout resuelto de la ruta actual:

    const layout = useLayout()
    

    Named views por convención de nombre. Múltiples salidas de renderizado usando el nombre del fichero:

    app/pages/parent/child.vue
    app/pages/parent/child@sidebar.vue
    

    La opción enabled en useFetch y useAsyncData. Condiciona si la petición puede ejecutarse:

    const query = ref('')
    
    const { data } = await useFetch('/api/search', {
      query: { q: query },
      enabled: () => query.value.length > 2,
    })
    

    Sustituye la guarda manual dentro del watch por una barrera declarativa. Pero léete la letra pequeña antes de usarlo: mientras enabled es false se bloquea todo, incluidos execute, refresh y los disparos del watch. Y volver a true no relanza la petición por sí solo. Necesitas una fuente reactiva — la opción query del ejemplo — para que se dispare. Si pones solo enabled con una URL estática, la petición nunca sale y no hay ningún error que te lo diga.

    Cuándo Nuxt tiene sentido y cuándo no

    Voy a ser honesto, porque un post que solo vende no te sirve de nada.

    Nuxt encaja cuando necesitas SEO real con contenido dinámico, cuando quieres frontend y backend en un mismo repositorio sin montar dos despliegues, y cuando el equipo ya conoce Vue. También encaja en productos que empiezan pequeños y aún no sabes si necesitarán servidor: Nitro te deja cambiar de destino de despliegue casi sin tocar código.

    Nuxt no encaja si lo que tienes es una landing estática con tres secciones. Ahí estás pagando un runtime que no necesitas y Astro hace ese trabajo con menos JavaScript enviado al navegador. Tampoco encaja si tu equipo es de Angular y no hay ninguna intención de mantener Vue: el meta-framework no es el problema, el ecosistema paralelo sí.

    Y hay un tercer caso que veo mucho: aplicaciones detrás de login, sin SEO, donde una SPA normal resuelve igual. Puedes usar Nuxt en modo cliente, claro. Pero si desactivas lo que lo hace especial, pregúntate qué estás comprando.

    Sobre Nuxt 5: está en desarrollo e incluirá Nitro v3 más cambios adicionales. La roadmap oficial marcaba Q1 de 2026 como estimación, una fecha que ya quedó atrás. Lo relevante para ti no es cuándo sale, sino la política: Nuxt se compromete a soportar cada major un mínimo de seis meses tras la salida del siguiente. Traducido: cuando aparezca la 5, tu proyecto en 4 tiene medio año de margen garantizado. Esa previsibilidad vale más que cualquier feature.

    Qué hacer hoy

    Si mantienes un proyecto en Nuxt 3, el soporte oficial termina el 31 de julio de 2026. Pasada esa fecha no hay parches de seguridad. Empieza por lo aburrido: sube a Node 22, actualiza la dependencia y mueve tu código de aplicación dentro de app/. Ese movimiento de carpetas es el grueso de la migración, y no hace falta que lo hagas a mano — hay un codemod oficial:

    npx codemod@latest nuxt/4/file-structure
    

    Si no has tocado Nuxt nunca, crea un proyecto con el comando de arriba, mete un server/api/hello.ts, consúmelo con useFetch desde una página y observa la petición en la pestaña de red. Vas a ver que en la primera carga no hay ninguna llamada al API: el servidor ya trajo los datos. Ese momento explica Nuxt mejor que cualquier artículo.

    Y si quieres construir algo real con esto sin pasar tres semanas dando vueltas a la arquitectura, es exactamente el proceso que trabajo en el curso de Construye con IA: de idea a producto con especificación primero y sin caos. En Dominicode Labs seguimos estos cambios de versión según salen, con los proyectos completos.

    Preguntas frecuentes

    ¿Puedo seguir usando Nuxt 3 después del 31 de julio de 2026?

    Tu aplicación va a seguir funcionando, no se apaga sola. Lo que termina es el soporte: Nuxt 3 recibe actualizaciones de mantenimiento con corrección de bugs y parches de seguridad hasta finales de julio de 2026. A partir de ahí, un fallo de seguridad en el framework se queda sin arreglar oficialmente. Para cualquier cosa en producción, esa es razón suficiente para planificar la migración.

    ¿Es obligatorio mover mi código a la carpeta app/?

    No. La documentación oficial dice literalmente que la migración "no es obligatoria": Nuxt autodetecta la estructura antigua y sigue funcionando. Dicho eso, app/ es la convención oficial y lo que asumen el material y los módulos nuevos, así que retrasarlo solo aplaza el trabajo. El código de aplicación (assets, components, composables, layouts, middleware, pages, plugins, utils, más app.vue, app.config.ts y error.vue) va dentro de app/, mientras que server/, public/, content/, layers/, modules/ y shared/ se quedan en la raíz.

    ¿Cómo migro de Nuxt 3 a Nuxt 4?

    Actualiza a Node.js 22, sube la dependencia de Nuxt y mueve el código de aplicación dentro de app/. Ese movimiento de carpetas es el grueso del trabajo y hay un codemod oficial que lo automatiza: npx codemod@latest nuxt/4/file-structure. La configuración (nuxt.config.ts) y la carpeta server/ se quedan donde están.

    ¿Puedo mantener la estructura de Nuxt 3 en Nuxt 4?

    Sí. Nuxt 4 autodetecta la estructura antigua y funciona sin cambios. También puedes forzarla explícitamente con srcDir: '.' en nuxt.config.ts. Es una salida válida para ganar tiempo en un proyecto grande, no una decisión que quieras mantener a largo plazo.

    ¿Cuál es la diferencia entre useFetch y $fetch?

    $fetch es la utilidad de red básica, sin protección para SSR ni deduplicación, pensada para peticiones disparadas por eventos del usuario. useFetch la envuelve y garantiza que en renderizado universal los datos se piden una sola vez, sin repetir la llamada al hidratar en el cliente. Regla simple: datos iniciales del componente con useFetch, acciones del usuario con $fetch.

    ¿Qué versión de Node.js necesito para Nuxt 4?

    Node.js 22.x o superior, y la documentación recomienda usar la versión LTS activa. Si vienes de un entorno con Node 20, actualiza antes de crear el proyecto: los errores por versión antigua no siempre indican con claridad cuál es la causa real.

    ¿Necesito crear la carpeta app/pages/ siempre?

    No, es opcional. Una aplicación de una sola pantalla puede vivir solo en app/app.vue. Ahora bien, si quieres routing por ficheros y mantienes tu propio app.vue, tienes que incluir el componente <NuxtPage> dentro para que Nuxt sepa dónde renderizar la página activa.

    ¿Cómo escribo un endpoint de API en Nuxt 4?

    Creas un fichero dentro de server/api/ en la raíz del proyecto y exportas un defineEventHandler. Un server/api/hello.ts queda disponible en /api/hello. Para leer datos usas getRouterParam(event, 'nombre'), getQuery(event) y await readBody(event). El método HTTP se define con el sufijo del fichero, como submit.post.ts.

    ¿Nuxt 4 usa TypeScript por defecto?

    Sí, los proyectos vienen configurados con TypeScript desde el inicio y Nuxt genera tipos automáticamente para rutas, componentes y las respuestas de tus endpoints de servidor. Si te interesa hacia dónde va el tipado en general, escribí sobre el nuevo compilador de TypeScript en Go y cómo cambia los tiempos de compilación.


    Si prefieres ver esto en vídeo, en el canal de YouTube de Dominicode publico este tipo de análisis de versiones cada semana.

    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Cómo construir un agente de IA y su MCP server paso a paso

    Cómo construir un agente de IA y su MCP server paso a paso

    Hace tres semanas un dev me escribió por Telegram con un MCP server funcionando. Lo había construido siguiendo un tutorial. Arrancaba, registraba sus tools, Claude Code lo detectaba. Todo perfecto.

    Su pregunta era: "¿y ahora cómo hago que mi agente lo use?".

    No supo responderse porque el tutorial terminaba justo ahí. Y ese es el problema con casi todo el material que hay sobre MCP: te enseñan a construir el enchufe, pero nunca el aparato que se enchufa. O al revés — te enseñan a montar un agente con tools locales y jamás mencionan por qué querrías sacarlas a un servidor.

    Son dos mitades de la misma pieza. Y separadas no sirven de mucho.

    Este post construye las dos. Un MCP server real en TypeScript, un agente que lo consume, y el puente entre ambos. Código verificado contra el SDK, no de memoria. Al final sabrás también cuándo no deberías montar un MCP server, que es una decisión que mucha gente se salta.


    Las dos mitades: quién expone y quién consume

    Un MCP server expone capacidades: tools, resources y prompts. No tiene inteligencia. No decide nada. Es un catálogo de funciones con un contrato estándar delante. Si no tienes claro el concepto de fondo, este post explica qué es Model Context Protocol antes de meterte en código.

    Un agente es lo contrario: tiene el modelo, tiene el bucle, y decide qué llamar y cuándo. Lo que no tiene es acceso a tu mundo — a tu base de datos, a tu API interna, a tus postmortems.

    MCP es el estándar que une las dos cosas sin que se conozcan entre sí. Escribes el server una vez, y lo consumen Claude Code, Claude Desktop, tu agente propio y el agente que escriba tu compañero el mes que viene.

    Esa reutilización es todo el valor de MCP. Recuérdalo, porque en la última sección lo usaremos para decidir si te hace falta.

    Vamos a construir un server sobre un caso que a cualquiera con sistemas en producción le suena: un histórico de incidencias. Datos internos, API que nadie más va a integrar, y consultas que un modelo puede hacer mucho mejor que un dashboard.


    Paso 1: el MCP server en TypeScript

    Construir un MCP server en TypeScript requiere tres piezas: el paquete @modelcontextprotocol/sdk con zod como peer dependency, una instancia de McpServer, y un transporte stdio. Este paso monta las tres sobre un caso real.

    Primero, versiones. Y aquí hay que ser preciso porque el ecosistema está en transición.

    La versión de producción hoy es la v1, en el paquete @modelcontextprotocol/sdk (última: 1.29.0). Es sobre la que vas a construir. Hay una v2 en beta que lo cambia bastante, y le dedico una sección entera más abajo — pero no construyas sobre ella todavía.

    mkdir mcp-incidencias && cd mcp-incidencias
    npm init -y
    npm install @modelcontextprotocol/sdk zod
    npm install -D typescript @types/node tsx
    

    zod es peer dependency obligatoria del SDK v1, no es opcional.

    En tu package.json añade "type": "module", y en el tsconfig.json usa "module": "NodeNext" y "target": "ES2022". Sin eso los imports con extensión .js te van a dar guerra.

    Ahora el servidor. Archivo src/server.ts:

    import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
    import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
    import { z } from 'zod';
    
    type Severidad = 'baja' | 'media' | 'alta';
    
    interface Incidencia {
      id: string;
      servicio: string;
      fecha: string;
      severidad: Severidad;
      titulo: string;
      causaRaiz: string;
    }
    
    // En producción esto sale de tu base de datos.
    const INCIDENCIAS: Incidencia[] = [
      {
        id: 'INC-101',
        servicio: 'checkout-api',
        fecha: '2026-05-14',
        severidad: 'alta',
        titulo: 'Timeouts masivos en pasarela de pago',
        causaRaiz: 'Pool de conexiones agotado tras un deploy sin migrar el límite.'
      },
      {
        id: 'INC-118',
        servicio: 'checkout-api',
        fecha: '2026-06-02',
        severidad: 'media',
        titulo: 'Latencia elevada en cálculo de impuestos',
        causaRaiz: 'Consulta N+1 introducida al añadir el desglose por región.'
      },
      {
        id: 'INC-124',
        servicio: 'auth-service',
        fecha: '2026-06-21',
        severidad: 'alta',
        titulo: 'Sesiones invalidadas de forma masiva',
        causaRaiz: 'Rotación de claves JWT desplegada sin periodo de solapamiento.'
      }
    ];
    
    const server = new McpServer({ name: 'incidencias', version: '1.0.0' });
    

    Fíjate en los imports: llevan la extensión .js y la ruta interna del paquete (/server/mcp.js). No es @modelcontextprotocol/sdk a secas. Es el error más habitual al empezar.

    Ahora registramos la primera tool:

    const ORDEN: Record<Severidad, number> = { baja: 0, media: 1, alta: 2 };
    
    server.registerTool(
      'buscar_incidencias',
      {
        title: 'Buscar incidencias',
        description:
          'Busca incidencias de producción de un servicio, filtrando por severidad mínima. ' +
          'Úsala cuando necesites el histórico de fallos de un servicio concreto.',
        inputSchema: {
          servicio: z.string().describe('Nombre del servicio, por ejemplo: checkout-api'),
          severidadMinima: z.enum(['baja', 'media', 'alta']).default('baja')
        },
        outputSchema: {
          total: z.number(),
          incidencias: z.array(
            z.object({
              id: z.string(),
              fecha: z.string(),
              severidad: z.string(),
              titulo: z.string()
            })
          )
        }
      },
      async ({ servicio, severidadMinima }) => {
        const encontradas = INCIDENCIAS.filter(
          (i) => i.servicio === servicio && ORDEN[i.severidad] >= ORDEN[severidadMinima]
        ).map(({ id, fecha, severidad, titulo }) => ({ id, fecha, severidad, titulo }));
    
        const output = { total: encontradas.length, incidencias: encontradas };
    
        return {
          content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
          structuredContent: output
        };
      }
    );
    

    Un detalle que ahorra tardes: inputSchema acepta tanto la forma en crudo ({ servicio: z.string() }) como un z.object() completo. El SDK normaliza las dos por dentro y el JSON Schema que acaba llegando al modelo es idéntico. Uso la forma cruda porque es menos ruido, pero si vienes de otra librería y te sale envolver, no rompes nada.

    Guárdalo, porque dentro de un momento vamos a ver otra librería donde esa flexibilidad no existe.

    Lo segundo: la description no es documentación, es prompt. Es literalmente lo único que el modelo lee para decidir si usa esta tool. Una descripción vaga es una tool que nunca se llama, o que se llama cuando no toca. Escríbela pensando en el modelo, incluyendo cuándo usarla.

    Segunda tool, con manejo de errores:

    server.registerTool(
      'detalle_incidencia',
      {
        title: 'Detalle de incidencia',
        description: 'Devuelve la causa raíz completa de una incidencia por su ID (formato INC-XXX).',
        inputSchema: {
          id: z.string().describe('Identificador, por ejemplo: INC-101')
        }
      },
      async ({ id }) => {
        const incidencia = INCIDENCIAS.find((i) => i.id === id);
    
        if (!incidencia) {
          return {
            content: [{ type: 'text', text: `No existe ninguna incidencia con ID ${id}.` }],
            isError: true
          };
        }
    
        return {
          content: [
            {
              type: 'text',
              text: `${incidencia.id} — ${incidencia.titulo}\nServicio: ${incidencia.servicio}\nFecha: ${incidencia.fecha}\nSeveridad: ${incidencia.severidad}\nCausa raíz: ${incidencia.causaRaiz}`
            }
          ]
        };
      }
    );
    

    isError: true en lugar de lanzar una excepción. La diferencia importa: con isError el modelo recibe el mensaje y puede corregirse solo — reintentar con otro ID, o decirle al usuario que no existe. Si lanzas, revientas la conexión y el agente se queda ciego.

    Y el arranque:

    const transport = new StdioServerTransport();
    await server.connect(transport);
    
    console.error('MCP server de incidencias escuchando en stdio');
    

    console.error, nunca console.log. En transporte stdio, stdout es el canal JSON-RPC. Un solo console.log mete texto suelto en la tubería y rompe el protocolo con un error de parseo que no dice nada útil. Todo tu logging va a stderr.

    Ya tienes la mitad de la pieza. Si quieres comprobar que funciona antes de seguir, no hace falta registrarlo en ningún cliente: npx @modelcontextprotocol/inspector npx tsx src/server.ts levanta MCP Inspector, la herramienta oficial, y te deja ver las tools registradas e invocarlas a mano desde el navegador.

    Si además quieres usarlo desde Claude Code, tengo aparte las notas sobre registrar un MCP server en Claude Code — es el complemento natural de esta sección, no un camino alternativo.


    Paso 2: el agente que consume las tools

    El agente se construye con el Tool Runner del SDK de Anthropic (client.beta.messages.toolRunner), que ejecuta por ti el bucle completo: llama al modelo, ejecuta la tool que pida, le devuelve el resultado, y repite hasta obtener una respuesta final.

    Aquí hay una confusión que veo constantemente y conviene despejarla antes de escribir una línea, porque son dos productos distintos de Anthropic:

    • Tool Runner (client.beta.messages.toolRunner, dentro del SDK normal @anthropic-ai/sdk): automatiza el bucle sobre las tools que defines. Sin tools integradas, sin sandbox. Tú pones todo.
    • Claude Agent SDK (@anthropic-ai/claude-agent-sdk): es Claude Code empaquetado como librería, con tools integradas de serie — leer y escribir ficheros, bash, grep.

    No son versiones distintas de lo mismo. Para este caso queremos el Tool Runner, porque lo que nos interesa es controlar exactamente qué tools existen.

    npm install @anthropic-ai/sdk
    

    Un agente mínimo con una tool local:

    import Anthropic from '@anthropic-ai/sdk';
    import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod';
    import { z } from 'zod';
    
    const client = new Anthropic(); // lee ANTHROPIC_API_KEY del entorno
    
    const buscarIncidencias = betaZodTool({
      name: 'buscar_incidencias',
      description: 'Busca incidencias de producción de un servicio por severidad mínima.',
      inputSchema: z.object({
        servicio: z.string().describe('Nombre del servicio, por ejemplo: checkout-api'),
        severidadMinima: z.enum(['baja', 'media', 'alta']).default('baja')
      }),
      run: async (input) => {
        // aquí llamarías a tu API real
        return JSON.stringify({ servicio: input.servicio, total: 0, incidencias: [] });
      }
    });
    
    const respuesta = await client.beta.messages.toolRunner({
      model: 'claude-opus-4-8',
      max_tokens: 16000,
      thinking: { type: 'adaptive' },
      messages: [
        {
          role: 'user',
          content: '¿Qué incidencias graves ha tenido checkout-api? Resume el patrón que veas.'
        }
      ],
      tools: [buscarIncidencias]
    });
    
    console.log(respuesta.content);
    

    Fíjate en la diferencia con el server: en betaZodTool el inputSchema tiene que ser un z.object() completo. Aquí no hay normalización que te salve — pasarle la forma en crudo no funciona.

    Las tres convenciones de schema que se confunden entre sí

    El mismo concepto cambia de formato según la librería. Es la causa más frecuente de tools que se registran pero nunca se llaman bien:

    Librería y función Propiedad Formato esperado
    MCP SDK v1 — registerTool inputSchema Forma cruda o z.object(); el SDK normaliza las dos
    Anthropic SDK — betaZodTool inputSchema z.object() completo, obligatorio
    Anthropic SDK — betaTool inputSchema JSON Schema plano (el que devuelve listTools())

    Las tres reciben inputSchema en camelCase. El input_schema en snake_case que quizá tengas visto es lo que viaja por la red hacia la API, no lo que le pasas al helper. Escribir snake_case en cualquiera de los tres revienta con un TypeError.

    Tres cosas más sobre los parámetros, que están cambiadas respecto a lo que quizá tengas memorizado:

    • El modelo es claude-opus-4-8. Sin sufijo de fecha.
    • thinking va con { type: 'adaptive' }. budget_tokens está eliminado en Opus 4.8 y devuelve un 400.
    • temperature, top_p y top_k rechazan cualquier valor que no sea el por defecto y devuelven 400. Si arrastras un temperature: 0 de un proyecto viejo, esa llamada falla. Se dirige el comportamiento por prompt, no por sampling.

    max_tokens alrededor de 16000 para peticiones normales, hasta ~64000 si haces streaming.

    El Tool Runner ejecuta el bucle completo por ti: llama al modelo, si pide una tool la ejecuta, le devuelve el resultado, y repite hasta que el modelo da una respuesta final. Ese bucle es el corazón de cualquier agente — y si quieres entender por qué la calidad del bucle importa más que el modelo que metas dentro, lo desarrollo aquí. Para los fundamentos de la API, este crash course te cubre.


    Paso 3: conectar las dos mitades

    Conectar el agente con el MCP server consiste en levantar un cliente MCP, pedirle sus tools con listTools() y traducirlas al formato del Tool Runner con betaTool. Así el agente ejecuta las tools reales del server en vez de copias locales.

    Ahora lo que casi nadie enseña. El agente del paso anterior tiene la tool duplicada en local. Queremos que consuma las tools reales del MCP server, sin reescribirlas.

    Para eso montamos un cliente MCP, le preguntamos qué tools tiene, y las traducimos al formato del Tool Runner:

    import Anthropic from '@anthropic-ai/sdk';
    import { betaTool } from '@anthropic-ai/sdk/helpers/beta/json-schema';
    import { Client } from '@modelcontextprotocol/sdk/client/index.js';
    import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
    
    const transport = new StdioClientTransport({
      command: 'npx',
      args: ['tsx', 'src/server.ts']
    });
    
    const mcp = new Client({ name: 'agente-incidencias', version: '1.0.0' });
    await mcp.connect(transport);
    
    // 1. Descubrimos las tools que expone el server
    const { tools } = await mcp.listTools();
    
    // 2. Las convertimos en tools ejecutables para el Tool Runner
    const puente = tools.map((tool) =>
      betaTool({
        name: tool.name,
        description: tool.description ?? '',
        inputSchema: tool.inputSchema as any,
        run: async (input) => {
          const resultado = await mcp.callTool({
            name: tool.name,
            arguments: input as Record<string, unknown>
          });
          return JSON.stringify(resultado.content);
        }
      })
    );
    
    // 3. El agente ya usa las tools reales del server
    const respuesta = await new Anthropic().beta.messages.toolRunner({
      model: 'claude-opus-4-8',
      max_tokens: 16000,
      thinking: { type: 'adaptive' },
      messages: [
        {
          role: 'user',
          content:
            'Revisa las incidencias graves de checkout-api, mira el detalle de cada una ' +
            'y dime si hay un patrón común en las causas raíz.'
        }
      ],
      tools: puente
    });
    
    console.log(respuesta.content);
    await mcp.close();
    

    Eso es el circuito completo. Aquí usamos betaTool en lugar de betaZodTool porque listTools() devuelve JSON Schema, no Zod. Encaja directo: le pasas el inputSchema tal cual llega del server.

    Y ojo con el detalle que más despista de todo el post: los helpers reciben inputSchema en camelCase, pero lo que viaja a la API es input_schema en snake_case. Si escribes tools a mano contra la API cruda usas snake_case; con los helpers, siempre camelCase. Escribir input_schema: dentro de betaTool no da un error de validación bonito — revienta con un TypeError antes de tocar la red.

    Cuando entiendas el mecanismo, el SDK ya trae ese puente hecho:

    import { mcpTools } from '@anthropic-ai/sdk/helpers/beta/mcp';
    
    const puente = mcpTools(tools, mcp);
    

    Merece la pena haber escrito el map a mano una vez: cuando el helper falle, sabrás qué está haciendo por dentro.

    Lo potente es que ese map no sabe nada de tus tools. Añade una tercera tool al server y el agente la tiene disponible en el siguiente arranque, sin tocar el código del agente. Ahí es donde MCP paga lo que cuesta.

    Al ejecutarlo verás al agente encadenar solo: llama a buscar_incidencias, recibe dos IDs, llama a detalle_incidencia con cada uno, y razona sobre las causas raíz. Nadie le dijo el orden.

    La otra vía: el conector MCP remoto

    Si en vez de stdio despliegas el server sobre HTTP, la API de Claude puede conectarse a él directamente, sin cliente MCP en tu código:

    const message = await client.beta.messages.create({
      model: 'claude-opus-4-8',
      max_tokens: 16000,
      betas: ['mcp-client-2025-11-20'],
      mcp_servers: [
        {
          type: 'url',
          url: 'https://incidencias.tudominio.com/mcp',
          name: 'incidencias'
        }
      ],
      tools: [{ type: 'mcp_toolset', mcp_server_name: 'incidencias' }],
      messages: [{ role: 'user', content: '¿Qué incidencias graves tuvo checkout-api?' }]
    });
    

    El conector MCP remoto exige declarar mcp_servers y tools a la vez. Declarar solo mcp_servers hace que la petición se rechace con un error de validación, aunque parezca redundante — ya has dicho dónde está el server, ¿para qué repetirlo? Cada server declarado en mcp_servers necesita su entrada { type: 'mcp_toolset', mcp_server_name: '<nombre>' } dentro de tools, y el mcp_server_name debe coincidir exactamente con el name del server.

    Ojo también: esta vía requiere un server con URL pública. Un server stdio no sirve aquí, para eso está el puente de arriba. La documentación del conector MCP de la API de Anthropic detalla el resto de campos disponibles.


    MCP v2 y la spec 2026-07-28: qué cambia y por qué no tienes que migrar

    El 28 de julio de 2026 salen la spec MCP 2026-07-28 y las SDK estables de la v2. Vas a ver posts en tono de urgencia. Ignóralos.

    La documentación oficial es explícita en dos puntos. Sobre las versiones, la v1.x sigue siendo "the supported release for production" y mantiene bugfixes y parches de seguridad al menos 6 meses después de que v2 sea estable. Y sobre el protocolo, la guía de la revisión lo cierra: "Nothing in v2 puts a 2026-07-28 byte on the wire by default" — hablar la revisión nueva es siempre un opt-in explícito. Migrar a v2 es opcional y va separado de la fecha del protocolo.

    Puedes seguir las dos fuentes de primera mano: la especificación del protocolo y el SDK de TypeScript en GitHub.

    El servidor que acabas de construir sigue funcionando. No hay nada que correr a arreglar.

    Dicho eso, la v2 no es un cambio cosmético y merece que sepas qué trae, porque cambia decisiones de arquitectura:

    Se parte el paquete. El monolítico @modelcontextprotocol/sdk desaparece en favor de @modelcontextprotocol/server, @modelcontextprotocol/client y @modelcontextprotocol/core. La v2 no sale como versión nueva del paquete viejo: son paquetes distintos. Por eso no hay riesgo de que te llegue sola en un npm update.

    Protocolo stateless. Cuando activas la revisión nueva, desaparecen el handshake initialize y la gestión de sesión. Traducido a infraestructura: escalas con un round-robin normal, sin sticky sessions. Si has sufrido balanceo con sesiones MCP, esta es la razón para migrar. Ojo: en la v2 el modo de 2025 sigue siendo el por defecto; la revisión 2026-07-28 se activa explícitamente.

    Multi Round-Trip Requests. Una tool puede pedir input al usuario a mitad de llamada, sin mantener un stream abierto — con inputRequired() y acceptedContent(). Confirmaciones y flujos de autorización dejan de ser un apaño.

    Cabeceras enrutables Mcp-Method y Mcp-Name, para que gateways y rate limiters enruten sin parsear el body. Si expones MCP detrás de un API gateway, esto te ahorra trabajo.

    Bring-your-own-schema. inputSchema y outputSchema aceptan cualquier Standard Schema: Zod v4 y ArkType directos, Valibot vía adaptador, o JSON Schema plano con fromJsonSchema. Se acabó estar atado a Zod. El registro pasa a server.registerTool(name, config, handler).

    Si vas a experimentar con la beta, el consejo oficial es fijar versiones exactas y poner cotas superiores en las dependencias, para no comerte un major por sorpresa.

    Mi recomendación: construye en v1, lee la guía de v2, y migra cuando tengas un motivo concreto — escalado horizontal o flujos que necesiten input a media llamada. No antes.


    Cuándo NO necesitas un MCP server

    Esta sección te puede ahorrar una semana.

    Si tu agente va a usar solo tus propias tools, dentro de tu propio proceso, no montes un MCP server. El Tool Runner con tools locales (betaZodTool y punto) es más simple, más rápido de depurar y no añade un proceso extra ni serialización por medio. Todo el paso 1 de este post sobra en ese escenario.

    MCP gana cuando aparece la palabra reutilización:

    • Quieres las mismas tools en Claude Code, en Claude Desktop y en tu agente.
    • Varios equipos van a consumir la misma capacidad y no quieres que cada uno la reimplemente.
    • Quieres una frontera de permisos clara: el server decide qué se puede hacer, el agente solo pide.
    • Necesitas versionar y desplegar las capacidades por separado del agente.

    Si no marcas ninguna, tu MCP server es una capa de indirección que no compra nada.

    Y una consecuencia que se ve poco: en cuanto varios clientes consumen tus tools, las descripciones dejan de ser tuyas y pasan a ser una API pública. Cambiar una description puede romper el comportamiento del agente de otro equipo sin que nada falle en rojo. Trátalas con el mismo cuidado que un contrato.


    Qué hacer con esto hoy

    Coge el código del paso 1, cámbiale el array INCIDENCIAS por una consulta real a tu base de datos, y ejecuta el puente del paso 3. En una tarde tienes un agente hablando con tus datos internos.

    Y cuando lo tengas funcionando, la parte difícil no será el código. Será decidir qué tools expones y cómo las describes — porque ahí es donde un agente pasa de demo a herramienta que usas todos los días.

    Ese salto, el de convertir una prueba de concepto en producto, es justo lo que trabajamos en el curso Construye con IA: De la Idea al Producto con Claude Code, con este mismo flujo de specs, tools y agentes. Si prefieres el método antes que la herramienta, en el libro Spec-Driven Development está el sistema completo para definir qué construyes antes de escribir la primera línea.

    Y si quieres los proyectos completos y las versiones de esto que corren en producción, están en Dominicode Labs.


    FAQ

    ¿Puedo usar el mismo MCP server con Claude Code y con mi agente propio a la vez?

    Sí, y es exactamente para lo que sirve MCP. El server no sabe quién le llama. Claude Code lo lanza como proceso hijo por stdio, y tu agente hace lo mismo con StdioClientTransport. Mismo binario, dos consumidores, cero código duplicado.

    ¿Tengo que migrar mi server a la v2 el 28 de julio?

    No. La v2 llega en paquetes nuevos (@modelcontextprotocol/server, /client y /core), no como actualización del paquete actual, así que no te va a llegar por un npm update. La v1.x sigue siendo la versión soportada para producción y mantiene bugfixes y parches de seguridad al menos 6 meses tras la salida de v2. Además, hablar la revisión 2026-07-28 es siempre un opt-in explícito: nada la pone en el cable por defecto.

    ¿Cuál es la diferencia real entre el Tool Runner y el Claude Agent SDK?

    El Tool Runner automatiza el bucle sobre tools que defines tú, y nada más: sin tools integradas, sin sandbox. El Claude Agent SDK es Claude Code como librería, y viene con tools de ficheros, bash y grep de serie. Si quieres control total sobre qué puede hacer el agente, Tool Runner. Si quieres un agente que opere sobre un repositorio desde el minuto uno, Agent SDK.

    Mi server arranca pero el cliente da error de parseo JSON. ¿Qué pasa?

    Casi seguro tienes un console.log en algún sitio. En stdio, stdout es el canal JSON-RPC exclusivo del protocolo. Cualquier texto que escribas ahí corrompe el flujo. Cambia todos los console.log por console.error y vuelve a probar.

    ¿Por qué mi tool aparece registrada pero el modelo nunca la llama?

    Dos causas, por frecuencia. La primera es la description: si es vaga, el modelo no sabe cuándo aplica. Escríbela diciendo explícitamente en qué situación usarla. La segunda es que el schema no describa bien los campos — añade .describe() a cada uno, porque el modelo los lee para saber con qué rellenarlos.

    ¿Cómo pruebo mi MCP server sin registrarlo en un cliente?

    Con MCP Inspector, la herramienta oficial: npx @modelcontextprotocol/inspector npx tsx src/server.ts. Abre una interfaz web donde ves las tools registradas, su schema, y puedes invocarlas con argumentos a mano. Es la forma más rápida de saber si el fallo está en el server o en cómo lo consume el cliente.

    ¿Puedo usar temperature para que el agente sea más determinista?

    No con Opus 4.8. temperature, top_p y top_k rechazan cualquier valor que no sea el por defecto y devuelven un 400. Tampoco existe ya budget_tokens para thinking — se usa thinking: { type: 'adaptive' }. Si migras código de modelos anteriores, revisa esos parámetros primero. El comportamiento se dirige por prompt.

    ¿El conector MCP de la API sirve para un server local por stdio?

    No. mcp_servers con type: 'url' necesita un endpoint HTTP accesible desde la API de Anthropic. Para un server local usas el puente del paso 3: cliente MCP por stdio y las tools traducidas al Tool Runner.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

    Más contenido sobre agentes e IA aplicada al desarrollo en el canal de YouTube de Dominicode. Y si estás empezando con agentes, esta guía es el punto de partida.

  • Cómo meter Hermes Agent en tu flujo de trabajo diario

    Cómo meter Hermes Agent en tu flujo de trabajo diario

    Llevo meses metiendo Hermes Agent en mi flujo de trabajo diario, y el momento en que decidí hacerlo en serio no fue leyendo la documentación. Fue una noche en la que tenía Claude Code abierto en una terminal, revisando un PR que no avanzaba. Slack abierto en otra pestaña, esperando una respuesta que tardaba. Y un cron corriendo a las 3am que revisaba PRs pendientes en tres repos con un script de bash que yo mismo mantenía a mano.

    Me detuve a mirar ese script y vi algo incómodo: acababa de reinventar, con cron y bash, exactamente lo que Hermes Agent hace nativo. Desde ese día dejó de ser un experimento de fin de semana — pasó a ser la pieza que corre en segundo plano mientras yo hago otra cosa.

    Para quien no lo tenga fresco: Hermes Agent es el framework open source de agentes autónomos de Nous Research — sandbox Docker, memoria persistente y soporte multicanal (CLI, Telegram, Discord, Slack, WhatsApp, Signal). Dicho eso, este post no es una intro de "qué es Hermes Agent". Es cómo lo uso yo: cuándo lo disparo desde el móvil en vez de abrir la laptop, dónde le doy acceso real a mi código sin miedo a que rompa nada, y qué reviso antes de conectarlo a un VPS con datos reales.


    Claude Code y Hermes Agent no compiten — resuelven turnos distintos

    La primera pregunta que me hacen es la obvia: ¿esto reemplaza a Claude Code? No. Si alguien te dice que sí, no lo ha usado en serio.

    Claude Code vive en tu editor. Es una sesión interactiva: tú escribes, el agente responde, revisas el diff, iteras. Pair programming con alguien que no se cansa. La sesión termina cuando cierras la terminal.

    Hermes Agent vive en otro sitio: en background, disparado por un evento — un mensaje, un cron, un webhook — y sigue corriendo aunque cierres la laptop.

    La regla que uso: si estoy decidiendo diseño en tiempo real, Claude Code. Si la tarea es "revisa esto, hazlo, y avísame" — y puedo estar en el metro sin laptop — es trabajo para Hermes Agent.


    Instalar Hermes Agent en menos de un minuto

    En Linux, macOS, WSL2 o Termux:

    curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
    

    En Windows nativo, sin WSL, desde PowerShell:

    iex (irm https://hermes-agent.nousresearch.com/install.ps1)
    

    El instalador de Windows resuelve solo uv, Python 3.11, Node.js, ripgrep, ffmpeg y un Git Bash portable — sin pedirte permisos de administrador. Esperaba instalar media docena de dependencias a mano. No hizo falta.


    Los comandos que necesitas el primer día

    Después de instalar, el wizard completo:

    hermes setup
    

    Si prefieres ir pieza por pieza:

    • hermes — abre el chat interactivo, punto de arranque de cualquier sesión
    • hermes model — elige proveedor y modelo LLM
    • hermes tools — configura qué herramientas están habilitadas
    • hermes gateway — levanta el gateway de mensajería (Telegram, Discord, Slack…)
    • hermes doctor — diagnostica problemas de configuración antes de que te den una sorpresa
    • hermes update — mantiene el binario en la última versión

    Si no quieres juntar API keys de cada proveedor por separado, hermes setup --portal hace login OAuth contra el Nous Portal: más de 300 modelos, web search, imágenes, TTS y browser en la nube bajo una sola suscripción. Es el atajo para no tener seis .env con llaves sueltas.


    Sacarlo de la terminal: dispararlo desde el móvil

    Aquí está el cambio real de flujo de trabajo. Antes de Hermes, "revisar algo desde el móvil" era abrir una app de VNC o SSH y sufrir un teclado táctil. Con el gateway de mensajería, no:

    hermes gateway setup    # Configura Telegram, Discord, Slack, WhatsApp, Signal
    hermes gateway start
    hermes gateway status
    

    El mismo agente que usas en la CLI responde en Telegram, Discord, Slack, WhatsApp o Signal, con los mismos slash commands:

    • /new o /reset — arrancar de cero
    • /model — cambiar de modelo
    • /personality — cambiar de contexto/personalidad
    • /retry y /undo — cuando algo sale mal
    • /compress — cuando la conversación se alarga
    • /usage — ver el gasto
    • /insights --days 7 — resumen semanal
    • /stop (o Ctrl+C en la CLI) — interrumpirlo

    En la práctica: voy caminando, me acuerdo de que quiero que revise un PR, le escribo por Telegram, y sigo caminando. Eso es lo que cambió — no la inteligencia del modelo, la fricción de acceder a él.


    El sandbox: que toque código real sin que te dé miedo

    La parte que a cualquier developer con experiencia le genera desconfianza, con razón: darle a un agente acceso de ejecución en tu máquina o servidor.

    Hermes soporta seis backends — local, Docker, SSH, Singularity, Modal, Daytona. Para cualquier cosa que toque un repo real, uso Docker (aquí entré en más detalle sobre por qué en la guía completa de Docker sandboxing en Hermes Agent):

    hermes config set terminal.backend docker
    

    No es un sandbox decorativo. El hardening por defecto elimina todas las capabilities de Linux y solo re-agrega tres: DAC_OVERRIDE, CHOWN, FOWNER. Límite de 256 procesos. /tmp como tmpfs de 512MB nosuid. /var/tmp con noexec y nosuid a 256MB. Bloqueo de escalación de privilegios (no-new-privileges). Límites de CPU, memoria (5GB por defecto) y disco (50GB por defecto).

    La diferencia práctica: si el agente ejecuta un comando destructivo dentro del sandbox, se lleva el contenedor, no tu servidor. Es la diferencia entre "cometí un error" y "cometí un error y ahora restauro un backup".


    Conectar las herramientas que ya usas: MCP

    Lo que hace que Hermes valga la pena en tu día a día no es que chatee bien — es que puede tocar las herramientas que ya usas. Los servidores MCP se declaran en ~/.hermes/config.yaml:

    mcp_servers:
      github:
        command: npx
        args: ["-y", "@modelcontextprotocol/server-github"]
        env:
          GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxx"
    

    Con eso conectado, el agente revisa issues, comenta PRs o abre ramas sin que tú abras GitHub. Si ya construyes servidores MCP para Claude Code, funcionan igual aquí — el protocolo es el mismo, el cliente cambia (si quieres el detalle completo de cómo montar un servidor MCP propio, lo cubrí en Model Context Protocol: conecta tu base de datos a la IA). Es la misma lógica de interoperabilidad que trabajamos en el curso Construye con IA al conectar agentes a herramientas reales de producción.


    El checklist antes de darle acceso a algo real (VPS, producción)

    Esto diferencia un despliegue de fin de semana de uno que no te explota en la cara. Antes de conectar Hermes a un VPS, reviso la lista completa, no las tres primeras líneas:

    1. Nunca actives GATEWAY_ALLOW_ALL_USERS=true — define allowlists explícitos por plataforma
    2. Usa el backend de contenedor (terminal.backend: docker) para aislar la ejecución
    3. Configura límites de recursos en ~/.hermes/config.yaml
    4. Guarda secretos en ~/.hermes/.env con permisos restringidos — nunca en config.yaml
    5. Usa códigos de DM pairing en vez de IDs de usuario hardcodeados
    6. Audita el command_allowlist con regularidad, no solo la primera vez
    7. Define terminal.cwd para limitar el directorio de trabajo del agente
    8. Corre el gateway como usuario no-root
    9. Monitorea ~/.hermes/logs/ — que no falle no significa que hizo lo correcto
    10. Mantente actualizado con hermes update

    Si quieres esta lista y la chuleta completa de comandos en una sola hoja para imprimir, la armé gratis aquí: dominicode.com/hermes-agent. Y si tu siguiente paso es un VPS propio, el paso a paso completo está en cómo desplegar Hermes Agent en tu propio VPS con Docker.


    El modo YOLO no es tan yolo como suena

    Hay un modo --yolo (también /yolo, o HERMES_YOLO_MODE=1) que salta las confirmaciones de comandos. Lo uso cuando confío en la tarea y no quiero aprobar cada paso — casi siempre dentro del sandbox de Docker, nunca contra mi máquina local sin aislar.

    Incluso en YOLO hay un blocklist permanente que no se salta nunca: rm -rf /, fork bombs, escritura directa a dispositivos. No es marketing, es una capa que existe pase lo que pase.

    De fábrica también trae:

    • Protección SSRF — bloquea IPs privadas, loopback, link-local, CGNAT y metadata de nube antes de cualquier fetch
    • Filtrado de credenciales en subprocesos MCP — solo pasa variables seguras como PATH, HOME, USER, LANG
    • Escaneo de context files contra prompt injection
    • Advisories de supply-chainhermes doctor te avisa directo si algo tiene una vulnerabilidad conocida

    Qué hacer hoy

    No necesitas resolver todo esto en una tarde. Esto es lo que haría en tu lugar.

    Instala Hermes hoy y corre hermes setup. No conectes nada todavía — úsalo desde la CLI un par de días, como probarías cualquier herramienta nueva.

    Cuando le confíes algo real, cambia el backend a Docker antes de darle acceso a un repo que te importe. Es un comando, no una migración.

    Y antes de conectarlo a un VPS o a mensajería pública, pasa por el checklist completo de arriba. Es la diferencia entre automatizar tu flujo de trabajo y crear un incidente de seguridad con tu nombre encima.

    Si estás diseñando cómo encajan Claude Code, Hermes y el resto de tu stack de IA — no solo conectando un agente suelto — es el tipo de conversación que tenemos cada semana en Dominicode Labs con developers que ya tienen esto en producción. (Ya estamos preparando, además, un curso completo dedicado solo a esto — sin fecha todavía, pero viene.)


    FAQ — Preguntas frecuentes sobre Hermes Agent

    ¿Hermes Agent es lo mismo que Claude Code?

    No. Claude Code es una sesión interactiva en tu editor para pair programming en tiempo real: tú decides, el agente ejecuta, revisas el diff al instante. Hermes Agent corre en background, disparado por mensajería o eventos, y sigue trabajando aunque cierres la laptop. Son complementarios, no competidores.

    ¿Necesito un servidor o VPS para usarlo?

    No para empezar. hermes corre local desde tu CLI en Linux, macOS, WSL2, Termux o Windows nativo. Un VPS se vuelve necesario cuando quieres el gateway de mensajería disponible 24/7 sin depender de que tu laptop esté encendida — ahí entra el checklist de seguridad de este post.

    ¿Es gratis?

    El framework es open source. Lo que cuesta es el consumo de tokens del proveedor que elijas con hermes model, o la suscripción del Nous Portal si usas hermes setup --portal para acceder a los 300+ modelos sin gestionar API keys sueltas.

    ¿Qué tan seguro es darle acceso a mi terminal?

    Depende del backend. Correr hermes directo contra tu máquina local sin sandbox es la opción de mayor riesgo. Cambiar a terminal.backend: docker te da capabilities reducidas, límites de proceso, memoria y disco, y contención real. Sumado al checklist de este post, es un nivel razonable para producción.

    ¿Puedo usarlo con modelos locales?

    Sí, vía Ollama, con su endpoint compatible con la API de OpenAI en localhost:11434/v1 — cualquier modelo con tool calling funciona. La restricción real es de contexto: el agente necesita al menos 64.000 tokens disponibles para el system prompt, los esquemas de herramientas y la conversación, así que un modelo local con ventana pequeña queda descartado desde el arranque. Prueba primero con un modelo mediano (14B-32B) que soporte tool calling y esa ventana de contexto antes de comprometerte a un flujo 100% local.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Servidor de herramientas para tu agente de IA (sin MCP)

    Servidor de herramientas para tu agente de IA (sin MCP)

    Un cliente me escribió en enero. Llevaba tres semanas intentando montar un servidor de herramientas para que un agente consultara el stock de su ecommerce.

    Tenía la API montada desde 2021. Endpoints limpios, autenticación, tests. Todo funcionando en producción con miles de peticiones al día. Pero estaba convencido de que para "conectarle IA" necesitaba reescribirlo todo como servidor de herramientas para agentes.

    No necesitaba reescribir nada. Necesitaba un archivo de 40 líneas.

    Ese es el malentendido más caro que veo ahora mismo entre developers senior: creer que las herramientas de un agente son una infraestructura nueva. No lo son. Tu API de negocio ya es el servidor de herramientas. Lo que falta es un adaptador delgado que traduzca entre el modelo y tus endpoints.

    Y sí, existe MCP: un estándar para exponer herramientas de forma interoperable. Aquí vamos por debajo, a la mecánica cruda, para que veas exactamente qué pasa entre el modelo y tu servidor. Si después quieres estandarizar y exponer estas mismas herramientas para cualquier cliente, eso ya lo cubrí aquí.

    Paso 1: el servidor de herramientas que no sabe nada de IA

    Un servidor de herramientas para agentes de IA es una API HTTP normal cuyos endpoints se exponen al modelo mediante un adaptador que declara, para cada operación, su schema de entrada y cuándo debe invocarse. No es infraestructura nueva: es tu API de negocio más una capa de traducción.

    Esta es la parte que la gente complica sin motivo. El servidor es una API HTTP normal. Sin SDK de IA. Sin dependencias raras. Sin una sola línea que mencione un modelo.

    Un catálogo de productos con Hono:

    npm install hono @hono/node-server
    
    // server/index.ts
    import { Hono } from "hono";
    import { serve } from "@hono/node-server";
    
    type Producto = {
      sku: string;
      nombre: string;
      categoria: "perifericos" | "monitores" | "audio";
      precio: number;
      stock: number;
    };
    
    const catalogo: Producto[] = [
      { sku: "TEC-65", nombre: "Teclado mecánico 65%", categoria: "perifericos", precio: 89.9, stock: 12 },
      { sku: "MON-27", nombre: "Monitor 27\" 144Hz", categoria: "monitores", precio: 279.0, stock: 3 },
      { sku: "AUD-XM", nombre: "Auriculares ANC", categoria: "audio", precio: 199.0, stock: 0 },
    ];
    
    const app = new Hono();
    
    app.get("/productos", (c) => {
      const categoria = c.req.query("categoria");
      const items = categoria
        ? catalogo.filter((p) => p.categoria === categoria)
        : catalogo;
      return c.json({ items });
    });
    
    app.get("/stock/:sku", (c) => {
      const producto = catalogo.find((p) => p.sku === c.req.param("sku"));
      if (!producto) return c.json({ error: "SKU no encontrado" }, 404);
      return c.json({ sku: producto.sku, stock: producto.stock, precio: producto.precio });
    });
    
    app.post("/pedido", async (c) => {
      const { sku, unidades } = await c.req.json<{ sku: string; unidades: number }>();
      const producto = catalogo.find((p) => p.sku === sku);
      if (!producto) return c.json({ error: "SKU no encontrado" }, 404);
      if (producto.stock < unidades) return c.json({ error: "Stock insuficiente" }, 409);
    
      producto.stock -= unidades;
      return c.json({ pedidoId: crypto.randomUUID(), sku, unidades, total: producto.precio * unidades });
    });
    
    serve({ fetch: app.fetch, port: 3000 });
    

    Léelo otra vez y busca la palabra "IA". No está.

    Esto importa más de lo que parece. Cuando mezclas la lógica de negocio con la capa del modelo, acabas con endpoints que solo sirven para el agente, imposibles de testear en aislamiento y que se rompen cada vez que cambias de proveedor.

    Manteniendo la separación, tu API sigue sirviendo a tu web, a tu app móvil y al agente. Tres consumidores, una fuente de verdad.

    Paso 2: el adaptador que convierte tu API en servidor de herramientas

    Ahora sí, la capa que traduce. Instalas el SDK:

    npm install @anthropic-ai/sdk zod
    

    Y defines las herramientas con betaZodTool, que te deja declarar el schema de entrada con Zod y la función que se ejecuta cuando el modelo pide esa herramienta:

    // agent/tools.ts
    import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
    import { z } from "zod";
    
    const API = "http://localhost:3000";
    
    export const buscarProductos = betaZodTool({
      name: "buscar_productos",
      description:
        "Devuelve el catálogo de productos, opcionalmente filtrado por categoría. " +
        "Llama a esto cuando el usuario pregunte qué productos hay disponibles, " +
        "pida recomendaciones o mencione una categoría concreta.",
      inputSchema: z.object({
        categoria: z
          .enum(["perifericos", "monitores", "audio"])
          .optional()
          .describe("Categoría por la que filtrar. Omítela para ver el catálogo completo."),
      }),
      run: async ({ categoria }) => {
        const url = categoria ? `${API}/productos?categoria=${categoria}` : `${API}/productos`;
        const res = await fetch(url);
        return JSON.stringify(await res.json());
      },
    });
    
    export const consultarStock = betaZodTool({
      name: "consultar_stock",
      description:
        "Devuelve stock y precio actuales de un SKU. " +
        "Llama a esto SIEMPRE antes de confirmar disponibilidad o precio a un usuario. " +
        "Nunca respondas de memoria sobre stock o precios.",
      inputSchema: z.object({
        sku: z.string().describe("Identificador del producto, por ejemplo TEC-65"),
      }),
      run: async ({ sku }) => {
        const res = await fetch(`${API}/stock/${sku}`);
        if (!res.ok) return `No existe ningún producto con SKU ${sku}`;
        return JSON.stringify(await res.json());
      },
    });
    

    Fíjate en las descripciones. No dicen solo qué hace la herramienta: dicen cuándo llamarla.

    Esto no es cosmética. Los modelos Opus recientes son conservadores pidiendo herramientas — si dudan, prefieren responder ellos. Una descripción prescriptiva del tipo "llama a esto siempre antes de confirmar precios" convierte una llamada probable en una llamada determinista. Una descripción como "consulta el stock" la deja al azar.

    Y el enum en categoria hace algo que nadie agradece hasta que falla: elimina de raíz que el modelo invente "periféricos" con tilde, "peripherals" o "teclados". Si un parámetro tiene un conjunto cerrado de valores, dilo en el schema. Aquí es donde Zod deja de ser una librería de validación y se convierte en el contrato entre el modelo y tu API — si quieres exprimir esa parte, la trabajo a fondo en el curso de Zod.

    Paso 3: el agente

    Aquí viene lo que te ahorra casi todo el código que la gente escribe a mano.

    crearPedido es la tercera herramienta y la dejo para el siguiente apartado, porque tiene truco:

    // agent/index.ts
    import Anthropic from "@anthropic-ai/sdk";
    import { buscarProductos, consultarStock, crearPedido } from "./tools";
    
    const client = new Anthropic(); // lee ANTHROPIC_API_KEY del entorno
    
    const finalMessage = await client.beta.messages.toolRunner({
      model: "claude-opus-4-8",
      max_tokens: 16000,
      tools: [buscarProductos, consultarStock, crearPedido],
      messages: [
        {
          role: "user",
          content: "¿Qué monitores tenéis? Si hay stock del de 27 pulgadas, pídeme dos.",
        },
      ],
    });
    
    console.log(finalMessage.content.find((b) => b.type === "text")?.text);
    

    Eso es el agente entero.

    toolRunner ejecuta el bucle agéntico completo: llama a la API, detecta que la respuesta trae bloques tool_use, ejecuta tu función run, devuelve el resultado como tool_result, y repite hasta que el modelo deja de pedir herramientas. Es beta y vive bajo client.beta.messages. El comportamiento completo está documentado en la guía oficial de tool use.

    Un detalle que se come a mucha gente: ese archivo usa await en el nivel superior, así que necesitas "type": "module" en tu package.json o no compila.

    Y tres cambios de la API actual que rompen código copiado de tutoriales viejos:

    Parámetro Estado en Opus 4.8 Qué usar
    temperature, top_p, top_k Eliminados — devuelven 400 Nada. Quítalos
    budget_tokens Ya no existe thinking: { type: "adaptive" }
    Control de esfuerzo output_config: { effort: "high" } (low a max)

    Si arrastras un temperature: 0 de un proyecto de 2024, tu agente no arranca. Y con thinking: adaptive el modelo decide cuánto piensa según la dificultad, en lugar de gastarte un presupuesto fijo en preguntas triviales.

    Si prefieres el bucle manual, puedes escribirlo, pero recuerda volcar el response.content completo al historial para preservar los bloques tool_use, y devolver cada tool_result con su tool_use_id. Los stop_reason que verás son end_turn, tool_use, max_tokens, pause_turn y refusal.

    Las herramientas destructivas van gateadas

    Una herramienta destructiva se gatea devolviendo el control al usuario dentro de la propia función run, antes del efecto secundario. No requiere bajar al bucle manual.

    Esto es lo que separa una demo de algo que puedes poner delante de un usuario.

    Tu herramienta crear_pedido cobra dinero. Enviar un email, borrar un registro o lanzar un despliegue son irreversibles. El error más común que veo es asumir que para meter aprobación humana hay que bajar al bucle manual.

    No hace falta. El gate vive dentro de la propia función run:

    // agent/tools.ts — mismo archivo, mismos imports
    export const crearPedido = betaZodTool({
      name: "crear_pedido",
      description:
        "Crea un pedido real y descuenta stock. Llama a esto solo cuando el usuario " +
        "haya confirmado explícitamente sku y cantidad.",
      inputSchema: z.object({
        sku: z.string().describe("SKU del producto"),
        unidades: z.number().int().positive().describe("Número de unidades"),
      }),
      run: async ({ sku, unidades }) => {
        const aprobado = await pedirConfirmacion(
          `¿Confirmas el pedido de ${unidades} x ${sku}?`
        );
        if (!aprobado) return "El usuario canceló el pedido. No se ha creado nada.";
    
        const res = await fetch(`${API}/pedido`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ sku, unidades }),
        });
        return JSON.stringify(await res.json());
      },
    });
    

    pedirConfirmacion es tuya. En un CLI son cinco líneas:

    import { createInterface } from "node:readline/promises";
    
    async function pedirConfirmacion(pregunta: string): Promise<boolean> {
      const rl = createInterface({ input: process.stdin, output: process.stdout });
      const respuesta = await rl.question(`${pregunta} (s/n) `);
      rl.close();
      return respuesta.trim().toLowerCase().startsWith("s");
    }
    

    En una app real es un modal, un mensaje de Slack o una fila en una tabla de aprobaciones pendientes. Da igual cuál: el contrato es el mismo, la función run se queda esperando.

    Y devolver "El usuario canceló el pedido" es una respuesta perfectamente válida para el modelo. La entiende, se detiene y se lo explica al usuario. No hace falta arquitectura extra.

    Dos cosas más que conviene saber. El modelo puede pedir varias herramientas en el mismo turno, y sus resultados vuelven todos juntos en un único mensaje de usuario — así que tus funciones run deben ser seguras ejecutándose en paralelo.

    Y si necesitas que el input valide exactamente contra tu schema, sin campos de más, existe strict: true a nivel de definición de herramienta. Ojo: opera sobre el JSON Schema final, que debe llevar additionalProperties: false y required bien puestos — si defines con Zod, revisa el schema que genera antes de activarlo.

    Menos herramientas, mejor descritas

    Cuando pasas de cinco herramientas, la calidad se desploma antes por descripciones vagas que por número.

    Un agente con tres herramientas que dicen con precisión cuándo usarse rinde mejor que uno con quince que dicen qué hacen. Si tienes cuatro endpoints que devuelven variantes de lo mismo, agrúpalos en una herramienta con un parámetro enum. El modelo elige mucho mejor entre valores de un enum que entre nombres de herramientas parecidos.

    Esto es diseño de interfaz, no prompting. Y como cualquier diseño de interfaz, se define antes de escribir el código — es exactamente el trabajo que describo en el libro de Spec-Driven Development: decidir el contrato antes que la implementación.

    Qué hacer hoy

    Abre tu API de siempre. Elige los tres endpoints que más consultas de usuario resolverían. Escribe un archivo tools.ts que los envuelva, con descripciones que digan cuándo llamarlos. Conéctalo al toolRunner.

    Tienes un agente funcionando esta tarde, sin tocar una línea de tu backend.

    Ese es el punto entero de este post: no construyes herramientas para IA, construyes una API normal y le pones un adaptador. Todo lo demás — el estándar, el transporte, el registro de herramientas — son decisiones que vienen después, cuando ya sabes qué herramientas necesitas de verdad.

    Si aún estás decidiendo qué piezas montar alrededor del agente, el stack de IA agéntica que uso en 2026 cubre las decisiones de infraestructura que vienen justo después de este archivo.

    Y si prefieres trabajarlo con otros developers que están en el mismo punto, en Dominicode Labs tenemos los proyectos y los patrones que usamos en producción.


    Preguntas frecuentes

    ¿Necesito MCP para conectar un agente a mi API?
    No. MCP es un estándar de interoperabilidad, útil cuando quieres que varios clientes distintos consuman tus mismas herramientas. Para un agente propio consumiendo tu propia API, un adaptador con betaZodTool y el tool runner del SDK es suficiente y tiene mucha menos superficie que mantener. Si tu caso sí es ese —varios clientes distintos consumiendo las mismas herramientas—, el montaje completo está en MCP Server en TypeScript.

    ¿Qué modelo debo usar para un agente con herramientas?
    claude-opus-4-8 es el modelo actual y más capaz de la familia Opus. Evita los identificadores con sufijo de fecha de generaciones anteriores: están retirados o desactualizados y el código que los usa deja de funcionar.

    ¿Por qué me da error 400 al enviar temperature?
    Porque en Opus 4.8 temperature, top_p y top_k están eliminados. Enviarlos devuelve un error. Para controlar el razonamiento usa thinking: { type: "adaptive" } y, si necesitas más esfuerzo, output_config: { effort: "high" }.

    ¿Cómo evito que el agente ejecute acciones destructivas sin permiso?
    Metiendo el gate dentro de la función run de la herramienta: pides confirmación y, si el usuario dice que no, devuelves un string tipo "el usuario canceló". No necesitas bajar al bucle manual para tener aprobación humana, que es el error habitual.

    ¿El modelo puede llamar a varias herramientas a la vez?
    Sí. Puede pedir varias en un mismo turno y sus resultados vuelven juntos en un único mensaje de usuario. Diseña tus funciones run para que sean seguras ejecutándose en paralelo.

    ¿Debo montar el servidor de herramientas aparte de mi API?
    No hace falta. Tu API de negocio ya es el servidor; la capa de tools es un cliente HTTP delgado que vive en el proceso del agente. Mantener esa separación te permite servir a tu web, tu app y tu agente desde la misma fuente de verdad.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • ExpressoTS 4.0, el framework TypeScript que planta cara a NestJS

    ExpressoTS 4.0, el framework TypeScript que planta cara a NestJS

    Cada cierto tiempo alguien me escribe con la misma pregunta: "Bezael, tengo que montar una API en Node. ¿Express pelado o NestJS?".

    Y la respuesta honesta durante años ha sido incómoda. Express te deja solo: sin DI, sin estructura, sin ciclo de vida. NestJS te da todo eso, pero a cambio de decoradores por todas partes y una curva que a un junior le cuesta semanas.

    En medio no había casi nada. Ese hueco es exactamente donde vive ExpressoTS 4.0, publicada el 17 de julio de 2026, casi veinte meses después de la v3, y que por primera vez no parece un proyecto experimental.

    Ojo con lo que voy a decir y con lo que no voy a decir. No voy a contarte que esto reemplaza a NestJS. No lo hace. El ecosistema de NestJS es incomparablemente más maduro: integraciones, documentación, gente que ya resolvió tu problema hace dos años. Lo que sí ha cambiado es que ahora existe una alternativa seria para quien quiere estructura sin cargar con todo el peso.

    Qué es ExpressoTS y por qué la 4.0 importa

    ExpressoTS es un framework TypeScript para backend sobre Node.js. Trae inyección de dependencias con contenedor IoC, routing, middleware, hooks de ciclo de vida, manejo de errores y bootstrap de la aplicación.

    Hasta la v3 era, básicamente, "Express con DI y decoradores". Útil, correcto, poco ambicioso.

    La 4.0 es otra cosa. Requiere Node.js >= 20.19.0, según las release notes oficiales de la v4.0.0, y trae bloques que hasta ahora te tocaba construir tú o importar de tres librerías distintas.

    Dónde queda cada uno, para que no tengas que deducirlo:

    Express ExpressoTS 4.0 NestJS
    Inyección de dependencias No Sí, contenedor IoC Sí, contenedor IoC
    Curva de aprendizaje Baja Media Alta
    Boilerplate Mínimo Moderado Alto
    Testing incluido No Sí, createTestApp()
    Observabilidad incluida No Sí, Studio local Vía integraciones
    Madurez del ecosistema Muy alta Baja Muy alta

    Voy a ir a los bloques que de verdad cambian cómo escribes el código.

    Interceptors: AOP sin montarte tu propio framework

    Los interceptors de ExpressoTS 4.0 aplican cross-cutting concerns —caching, reintentos, transformación de respuestas— con ejecución condicional declarativa: la condición vive en el decorador, no dentro del interceptor.

    Este es el titular de la release, y no había visto la idea tan limpia en otros frameworks del ecosistema.

    // PerformanceInterceptor viene incluido. CacheInterceptor lo escribes tú.
    @UseInterceptors(
        PerformanceInterceptor,
        whenInterceptor(
            (ctx) => ctx.request.headers["x-cache"] === "true",
            CacheInterceptor,
        ),
    )
    

    Léelo despacio. El PerformanceInterceptor corre siempre. El CacheInterceptor corre solo si la request trae esa cabecera. No hay un if dentro del interceptor decidiendo si le toca trabajar o no. La condición vive fuera, en la declaración.

    Parece un detalle estético. No lo es. La cantidad de código que he visto en producción donde un interceptor empieza con seis líneas de "¿me toca actuar ahora?" es descorazonadora. Ahí es donde nacen los bugs de "en staging cachea y en prod no".

    Tienes también unlessInterceptor() para el caso inverso, auto-discovery por decoradores y tres interceptors listos de fábrica: LoggingInterceptor, PerformanceInterceptor y TimeoutInterceptor.

    Y para componerlos, dos utilidades que redondean el argumento: pipeInterceptors() los encadena en orden, y combineInterceptors() los lanza en paralelo para trabajo de solo efecto secundario, como logging o métricas.

    Eventos tipados con prioridad

    El sistema de eventos de ExpressoTS 4.0 combina auto-discovery de handlers, routing condicional y ejecución por prioridad, con tipado end-to-end vía IEventHandler<T>:

    @OnEvent(UserCreatedEvent, { priority: 1 })
    export class SendWelcomeEmailHandler implements IEventHandler<UserCreatedEvent> {
        handle(event: UserCreatedEvent) { /* ... */ }
    }
    

    El IEventHandler<UserCreatedEvent> es lo que hace que esto valga la pena. En cuanto alguien cambie la forma del evento, el compilador te avisa en todos los handlers.

    Sin eso, un sistema de eventos es una lista de strings mágicos esperando a romperse en el peor momento.

    Configuración type-safe y validación pluggable

    La configuración se declara con validación y variación por entorno:

    export default defineConfig({
        database: {
            url: Env.string("DATABASE_URL", { required: true }),
        },
    });
    

    Si falta DATABASE_URL, la aplicación no arranca. No revienta a los veinte minutos en la primera query. Falla en el arranque, que es donde debe fallar.

    Y esto conecta con una decisión de diseño que aplaudo: la Smart Validation de v4 usa un registry de adapters pluggable. Soporta class-validator, Zod y Yup. No te casan con una librería.

    Si vas a montar algo nuevo con esto, mi recomendación es Zod. Esquema y tipo en la misma declaración, sin decoradores, sin duplicar la forma del dato en dos sitios. Y si te preocupa el coste de tipado en un proyecto grande, ese problema tiene fecha de caducidad: ya conté cómo el compilador de TypeScript reescrito en Go cambia el juego.

    Si nunca has llevado Zod más allá de z.object(), en el curso de Zod para TypeScript cubro justo la parte que la gente se salta: transforms, refinements y validación en los bordes del sistema.

    ExpressoTS Studio: local, no cloud

    ExpressoTS Studio es una plataforma de desarrollo local: no envía tu tráfico a ningún servidor externo. Es la decisión más valiente de la release y merece sección propia.

    Te da un dashboard de estado, un mapa de arquitectura generado en vivo desde el grafo de dependencias, un request timeline con spans de OpenTelemetry, logs en directo, inspección de errores, replay de tráfico y una auditoría de seguridad con scoring basada en el tráfico real de tu entorno de desarrollo.

    El mapa de arquitectura generado desde el grafo DI es la parte que más me gusta. Documentación de arquitectura que no se queda obsoleta porque nadie la actualiza: se deriva del código.

    Y que sea local en vez de SaaS elimina de golpe la conversación con legal antes de empezar.

    El resto, en corto

    Hay más, y no todo necesita párrafos:

    • Lazy-loading de módulos con rutas auto-detectadas desde @controller() y preload hints (high, medium, low, never). Debería mejorar los cold starts, que en serverless es dinero.
    • Módulo de testing con createTestApp() a cero configuración, API fluida para HTTP, snapshot testing y load testing con métricas de percentiles.
    • Logging de 11 fases: structured logging, transports a fichero, gestión de contexto, consulta de logs y export a Markdown.
    • Guards por rol, por permiso y resource-owner, con utilidades de composición.
    • Health monitoring en tres capas: middleware pipeline, providers IHealthCheck y dashboard agregado.
    • Content negotiation RFC 7231: JSON, XML, CSV y YAML.
    • Scopes DI personalizados: tenant, transaction, workflow, session. Si haces multi-tenant, esto te ahorra un patrón entero.
    • API versioning por URL con el decorador @Version().
    • Errores RFC 7807 (problem details) con exception filters y sugerencias de ruta en los 404.
    • Lifecycle hooks: globalConfiguration(), configureServices(), postServerInitialization(), serverShutdown().

    Si vienes de v3: lo que se rompe

    Migrar de v3 a v4 tiene tres breaking changes obligatorios:

    1. Los patrones de DI cambian. Revísalos uno a uno.
    2. Los lifecycle hooks de app.ts hay que actualizarlos.
    3. Sube el runtime a Node.js >= 20.19.0.

    En soporte, el equipo ha sido razonable: según su política publicada, v4.0.0 recibe 24 meses de bugfixes y parches de seguridad. La v3.x tenía 18 meses y los han extendido hasta diciembre de 2026 para que la migración no sea una carrera.

    Una migración así es un caso de manual para trabajar con especificación antes que con código: describes el estado destino, listas los puntos de cambio y solo entonces dejas que un agente te ayude a ejecutarlo módulo a módulo. Es la metodología que documenté en el libro de Spec-Driven Development, y funciona especialmente bien cuando el cambio es amplio pero mecánico.

    Mi veredicto

    ExpressoTS 4.0 no es un juguete. Interceptors condicionales, eventos tipados, scopes DI de tenant y transaction, Studio local: son decisiones de gente que ha sufrido aplicaciones grandes.

    ¿Lo llevaría a un proyecto crítico, con equipo de quince personas y entrega en tres meses? Todavía no. NestJS tiene ecosistema, integraciones probadas y una comunidad enorme, y eso pesa más que cualquier feature bonita cuando algo te falla un viernes.

    ¿Lo usaría en un servicio nuevo, un side project o una API interna donde el peso y los cold starts importan? Sin dudarlo.

    Instálalo y móntate algo pequeño esta semana:

    npx @expressots/cli new my-app
    

    Levanta Studio, mira el mapa de arquitectura que genera del grafo DI y decide con tu propio código delante. Media hora te basta para saber si te encaja. La documentación oficial está sorprendentemente bien para un proyecto de este tamaño.

    Y si quieres ver cómo integro frameworks nuevos como este en un flujo de trabajo con agentes de IA —specs primero, implementación asistida, tests de verdad— eso es justo lo que describo en mi stack de IA agéntica y lo que practicamos dentro de Dominicode Labs.

    Preguntas frecuentes

    ¿ExpressoTS 4.0 sustituye a NestJS?

    No, y no lo pretende. NestJS tiene un ecosistema mucho más maduro en integraciones, documentación y comunidad. ExpressoTS ocupa el hueco entre Express pelado y NestJS: te da DI, ciclo de vida y estructura con menos boilerplate y una curva más corta. Son opciones distintas, no una sustitución.

    ¿Qué diferencia hay entre ExpressoTS y Express?

    Express es un router HTTP minimalista: no trae inyección de dependencias, ni estructura de proyecto, ni ciclo de vida de aplicación. ExpressoTS se construye sobre esa base y añade contenedor IoC, decoradores para controllers, hooks de ciclo de vida, guards, interceptors y utilidades de testing. Con Express decides tú toda la arquitectura; con ExpressoTS parte ya viene decidida.

    ¿Qué versión de Node necesito para ExpressoTS 4.0?

    Node.js 20.19.0 o superior, según las release notes oficiales de la v4.0.0 (17 de julio de 2026). Es un breaking change respecto a v3, así que verifica el runtime de tu entorno de despliegue antes de migrar.

    ¿ExpressoTS Studio envía mis datos a la nube?

    No. Studio es una plataforma de desarrollo local. El dashboard, el mapa de arquitectura, el request timeline con spans OpenTelemetry, los logs y la auditoría de seguridad funcionan sobre el tráfico de tu entorno de desarrollo, en tu máquina.

    ¿Puedo usar Zod para validar en ExpressoTS 4.0?

    Sí. La Smart Validation de v4 funciona con un registry de adapters pluggable que soporta class-validator, Zod y Yup. Puedes elegir la librería que ya uses en el resto del proyecto.

    ¿Cuánto tiempo tengo para migrar desde v3?

    El soporte de v3.x se ha extendido hasta diciembre de 2026. La v4.0.0 recibe 24 meses de bugfixes y parches de seguridad desde su publicación en julio de 2026. Tienes margen para planificar la migración sin prisas.

    ¿Qué se rompe al migrar de ExpressoTS v3 a v4?

    Tres cosas: los patrones de inyección de dependencias cambian y hay que revisarlos uno a uno, los lifecycle hooks de app.ts necesitan actualizarse, y el runtime debe subir a Node.js 20.19.0 o superior.

    ¿ExpressoTS 4.0 sirve para serverless?

    Es uno de los escenarios donde mejor encaja. El lazy-loading de módulos carga solo lo necesario en cada invocación, con preload hints (high, medium, low, never) para afinar qué se precarga, lo que ayuda con los cold starts. Súmale que el core es ligero comparado con alternativas más pesadas del ecosistema.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.