Author: Dominicode

  • Self-healing code en agentes TypeScript: el bucle que sí corrige

    Self-healing code en agentes TypeScript: el bucle que sí corrige

    El self-healing code en agentes de TypeScript es un patrón sencillo de describir y fácil de implementar mal. Te cuento primero cómo me enteré.

    Un agente mío se pasó tres minutos razonando una tarea, escribió ochenta líneas de TypeScript, llamó a la API interna y devolvió el objeto con userId donde el schema pedía id.

    Una palabra.

    El pipeline hizo lo que hacen todos: lanzó la excepción, abortó con código de salida 1 y me mandó un aviso para que abriera el editor y cambiara esa palabra a mano.

    Lo absurdo es que Zod ya sabía exactamente qué había fallado. Sabía el campo, el tipo recibido, el tipo esperado y la ruta dentro del objeto. Tenía el diagnóstico completo escrito en una estructura de datos. Y con todo eso en la mano, el sistema decidió despertar a un humano.

    Así que monté el bucle de autocorrección. Y durante dos semanas no funcionó, gastando el doble de llamadas al modelo, por un motivo que no vi hasta que abrí el objeto de error con el debugger.

    Resumen rápido:

    • El self-healing convierte el diagnóstico de un verificador determinista en el contexto del siguiente intento, en vez de escalar a un humano.
    • Si usas generateObject del Vercel AI SDK, el detalle del fallo no está en error.message — está en error.cause. Ese es el error que arruina la mayoría de implementaciones.
    • Techo de dos intentos totales, y cuenta intentos, no reintentos.
    • No todos los errores son curables: los de tipos y schema sí, los de credenciales o herramienta caída no.

    Qué es el self-healing code (y qué no es)

    El self-healing code es un patrón en el que el sistema que genera —código o datos estructurados— ejecuta un verificador determinista, captura el diagnóstico exacto del fallo y lo reinyecta como contexto en un reintento acotado, en lugar de tratar el fallo como terminal.

    La idea de fondo: el validador es el mejor prompt que vas a escribir en tu vida, porque es el único que describe el fallo con precisión de campo y sin ambigüedad.

    El bucle tiene cinco pasos:

    1. Generar. El modelo produce el objeto o el código.
    2. Verificar. Zod, tsc o el test runner dictaminan. Sin intervención humana y sin LLM de por medio: determinista.
    3. Extraer el diagnóstico real. Campo, ruta, tipo esperado, tipo recibido. Aquí es donde falla casi todo el mundo.
    4. Reinyectar. El diagnóstico vuelve como turno nuevo de la conversación, junto a la salida anterior.
    5. Acotar. Techo de intentos y salida limpia cuando se agota.

    Conviene separarlo de dos patrones vecinos con los que se confunde.

    No es un retry con backoff. El backoff reintenta lo mismo esperando que el mundo cambie: que se descongestione la red, que el proveedor se recupere. El self-healing reintenta algo distinto, porque le has añadido información que antes no estaba. Si reintentas idéntico un fallo de validación, el modelo suele reproducir el mismo error.

    No es un circuit breaker. El breaker existe para dejar de insistir cuando una herramienta externa lleva minutos caída; lo conté en circuit breaker para agentes IA. Son capas distintas: el breaker mira la salud de un servicio externo, el self-healing mira la forma de lo que devuelve el modelo. En un agente serio acaban conviviendo.

    Y una frontera más: este post va del bucle. De cómo validar y tipar la respuesta en sí ya escribí en cómo tipar las respuestas de una LLM con Zod y TypeScript. Si no tienes esa parte montada, empieza por ahí y vuelve.


    El error que hace que tu bucle de autocorrección no sirva de nada

    Aquí está lo que me costó dos semanas.

    Cuando usas generateObject del Vercel AI SDK y el modelo devuelve algo que no valida, el SDK lanza un NoObjectGeneratedError. La reacción natural es esta:

    catch (error: any) {
      prompt = `Tu respuesta anterior falló con este error: ${error.message}`;
    }
    

    Ese código se ejecuta sin romperse, el bucle gira, gastas otra llamada al modelo y parece que el patrón funciona.

    No funciona. El message de un NoObjectGeneratedError es genérico —del tipo "No object generated"— y no lleva el campo, ni el tipo esperado, ni la ruta. Le estás diciendo al modelo "lo has hecho mal" y esperando que adivine el qué.

    El detalle está en otras propiedades del error, documentadas en el propio AI SDK:

    • error.cause — el error subyacente real: el ZodError con sus issues, o el fallo de parseo de JSON.
    • error.text — el texto crudo que el modelo llegó a generar, que le permite ver su propia salida y compararla con el diagnóstico.
    • error.finishReason — si vale 'length', el JSON no es inválido por confusión del modelo: está truncado porque se acabaron los tokens. Reintentar con el mismo límite es tirar dinero; ahí toca subirlo o partir el schema.

    Ese último matiz es la diferencia entre un bucle que corrige y un bucle que solo encarece la factura.

    Hay un segundo fallo igual de común, y es de contexto. Si en el reintento reasignas el prompt en vez de acumular la conversación, el modelo recibe "tu respuesta anterior falló, corrígela" sin la tarea original y sin su propia salida. generateObject no guarda historial: cada llamada es independiente. El modelo no sabe qué tenía que generar ni qué generó. No hay nada que corregir.


    Implementación del bucle en TypeScript

    Con eso claro, el bucle queda así. Versiones: AI SDK 5 y Zod 4.

    import { generateObject, NoObjectGeneratedError } from "ai";
    import { anthropic } from "@ai-sdk/anthropic";
    import { z } from "zod";
    
    const PaymentConfigSchema = z.object({
      customerId: z.string().min(5).describe("ID del cliente, prefijo cus_"),
      amountInCents: z.number().int().positive().describe("Importe en céntimos, nunca decimal"),
      currency: z.enum(["EUR", "USD"]),
      maxPaymentRetries: z.number().int().min(1).max(5),
    });
    
    type PaymentConfig = z.infer<typeof PaymentConfigSchema>;
    
    // Intentos TOTALES, no reintentos: 2 = la primera llamada y una corrección.
    const MAX_ATTEMPTS = 2;
    
    export async function generateSelfHealingConfig(
      userRequirement: string,
    ): Promise<PaymentConfig> {
      const messages: Array<{ role: "user" | "assistant"; content: string }> = [
        { role: "user", content: `Genera la configuración de pago para: "${userRequirement}"` },
      ];
    
      for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
        try {
          const { object } = await generateObject({
            model: anthropic("claude-opus-5"),
            schema: PaymentConfigSchema,
            messages,
            // Clave: el maxRetries del SDK son reintentos de TRANSPORTE (429, 5xx)
            // y vale 2 por defecto. Sin ponerlo a 0, cada vuelta de este bucle
            // puede disparar hasta 3 peticiones HTTP: 6 llamadas en el peor caso.
            maxRetries: 0,
          });
          return object;
        } catch (error) {
          if (!NoObjectGeneratedError.isInstance(error)) throw error; // 401, red, bugs propios
    
          // Truncado por tokens: reintentar igual no arregla nada.
          if (error.finishReason === "length") {
            throw new Error(
              "[SELF-HEALING] Respuesta truncada por límite de tokens: súbelo o parte el schema.",
            );
          }
    
          if (attempt === MAX_ATTEMPTS) {
            throw new Error(
              `[SELF-HEALING] Sin corregir tras ${MAX_ATTEMPTS} intentos:\n${formatIssues(error.cause)}`,
            );
          }
    
          // El feedback útil: su salida + el diagnóstico concreto, como turno nuevo.
          messages.push({ role: "assistant", content: error.text ?? "(sin salida)" });
          messages.push({
            role: "user",
            content:
              `Tu respuesta no cumple el schema:\n${formatIssues(error.cause)}\n\n` +
              `Corrige ÚNICAMENTE esos campos y devuelve el objeto completo.`,
          });
        }
      }
    
      throw new Error("[SELF-HEALING] Bucle terminado sin resultado");
    }
    
    // El diagnóstico en tres líneas, no el volcado entero.
    function formatIssues(cause: unknown): string {
      if (cause instanceof z.ZodError) {
        return cause.issues
          .map((i) => `· ${i.path.join(".") || "(raíz)"}: ${i.message}`)
          .join("\n");
      }
      return cause instanceof Error ? cause.message : String(cause);
    }
    

    Tres decisiones que no son cosméticas.

    maxRetries: 0. Es la que más gente se salta. El maxRetries del AI SDK vale 2 por defecto y cubre fallos de transporte —408, 409, 429 y 5xx— con backoff exponencial. Son compatibles con este bucle, pero se multiplican: dos vueltas tuyas por tres peticiones suyas son seis llamadas donde creías tener dos. Si quieres backoff de red, ponlo tú fuera y controla el total.

    El error vuelve como turno de conversación. Al empujar la salida fallida como mensaje assistant y la corrección como user, el modelo ve su propio intento enfrentado al diagnóstico. Reasignar el prompt original pierde ese contraste — es el segundo fallo que veíamos arriba.

    formatIssues recorta. Un ZodError serializado entero son cientos de caracteres de ruido que pagas en cada vuelta. Las issues mapeadas a ruta: mensaje son las tres líneas que importan.

    Si quieres exprimir la parte del schema —.describe(), uniones discriminadas, enums en vez de strings abiertos— es lo que trabajo en el curso de Zod para TypeScript. Un schema bien diseñado reduce cuántas veces entras en este bucle, que sigue siendo el mejor ahorro disponible.


    Las tres reglas para que esto no se convierta en un bucle infinito caro

    1. Pásale el diagnóstico, no el volcado

    Cinco mil caracteres de stack trace con trazas internas de Node entierran la señal en ruido, y encima los pagas en cada vuelta. Ruta, mensaje y tipo esperado. Nada más.

    2. Techo estricto, y cuenta intentos, no esperanzas

    Dos intentos totales. Si un modelo actual no arregla un fallo de forma teniendo delante el error exacto, el problema casi nunca es el modelo: es un schema que pide algo que el contexto no contiene. El tercer intento no corrige, factura.

    El fallo más común es contar mal. Un for (let i = 1; i <= maxRetries; i++) con maxRetries = 2 da dos intentos totales, es decir, un solo reintento. Si querías dos correcciones, el bucle se te queda corto y no te enteras. Por eso arriba la constante se llama MAX_ATTEMPTS.

    Este techo vive dentro del límite global de pasos del agente, no lo sustituye: sobre eso escribí en el agentic loop en producción.

    3. Corrección quirúrgica, no regeneración

    Pide explícitamente que corrija solo los campos señalados. Si dejas que regenere el objeto entero, es habitual que arregle el campo roto y rompa otro que ya estaba bien — y con techo de dos intentos te quedas sin margen.


    Un oráculo por cada tipo de error

    Zod valida la forma de los datos en runtime. Es la primera capa, no la única: el patrón es idéntico cambiando quién emite el diagnóstico.

    Oráculo Qué detecta Qué le pasas al modelo
    Zod Salida estructurada que no cumple el schema issues mapeadas a ruta: mensaje
    tsc --noEmit Errores de tipos en el código generado Código de error, archivo, línea, tipo esperado vs recibido
    Vitest / Jest Errores de lógica de negocio Nombre del test y el diff esperado/recibido
    ESLint Estilo y patrones prohibidos Nada: esto se arregla con --fix, no con el modelo

    El compilador. Cuando el agente escribe código en vez de devolver datos, tsc --noEmit da diagnósticos con archivo, línea y tipos enfrentados. Un Type '{ userId: string }' is not assignable to type '{ id: string }' es la misma señal que un ZodError: precisa, accionable y gratis. Pásale las líneas del diagnóstico, no la salida completa del compilador — en un proyecto mediano son cientos de líneas y un solo error de tipos suele arrastrar diez mensajes derivados del mismo origen.

    Los tests. El compilador y Zod atrapan errores de forma; los tests atrapan errores de fondo. Un agente que ejecuta la suite, lee qué aserción falló y corrige antes de enseñarte nada es la versión completa del patrón. Es también donde el techo se vuelve innegociable: un agente iterando contra una suite en rojo sin límite es la forma más rápida que conozco de quemar presupuesto. Y hay una trampa propia de esta capa: ejecuta la suite entera antes de aceptar el parche, no solo el test que fallaba. Arreglar el test A rompiendo el B es un resultado muy común y, si solo miras A, lo das por bueno.

    Para montar el entorno donde ese ciclo corre aislado, escribí sobre el test harness para desarrollo con agentes. Y ese salto —de validar datos a montar el ciclo entero de generar, verificar y corregir— es el hilo del curso Construye con IA: de la idea al producto con Claude Code.

    Un apunte de arquitectura: el bucle queda más limpio si los fallos ya viajan como datos tipados en lugar de excepciones sueltas, algo que conté en cómo manejar errores en agentes de IA con TypeScript.


    Qué errores son curables y cuáles no

    Aplicar el bucle a todo es peor que no tenerlo. Esta es la tabla que uso para decidir:

    Tipo de fallo ¿Self-healing? Qué hacer
    Error de tipos (tsc) Sí Reinyectar diagnóstico, 1 reintento
    Schema de salida inválido Sí Reinyectar error.cause + la tarea original
    Aserción de test fallida Sí, con cuidado Reinyectar el diff y correr la suite completa
    Respuesta truncada (finishReason: 'length') No Subir el límite de salida o partir el schema
    Lint y formato No Determinista: --fix
    Tool externa 5xx o timeout No Circuit breaker, no reintento
    Credenciales, 401 No Abortar y escalar
    Requisito ambiguo No Humano en el bucle
    Operación con efectos ya aplicados No Idempotencia o compensación

    Ese último merece un párrafo. Si el primer intento escribió en base de datos o llamó a un endpoint de cobro, reintentar no es autocorregir: es duplicar. El bucle solo es seguro mientras la operación no haya salido de tu proceso. Valida primero, ejecuta después.

    Y hay un coste que conviene tener presente: cada vuelta añade la latencia completa de una llamada al modelo y paga de nuevo los tokens del contexto acumulado, que ahora incluye la salida fallida y el diagnóstico. En un flujo interactivo, a veces es mejor devolver el fallo rápido que hacer esperar el doble para acertar. Si quieres saber en qué se te va de verdad el presupuesto, medir el consumo de tokens del agente es el paso previo.


    Cuando el segundo intento también falla

    El techo implica que existe un camino de salida, y ese camino no puede ser una excepción sin contexto que alguien encuentre en un log tres días después.

    Lo que funciona: registrar el fallo con las cuatro piezas que lo hacen reproducible —la tarea original, la salida del modelo, el diagnóstico del verificador y el número de intentos consumidos— y encolarlo. Ese registro sirve para dos cosas distintas. La inmediata, que alguien lo resuelva. La útil a medio plazo, que la cola se convierte en tu mejor fuente de mejoras del schema: cuando ves tres fallos seguidos sobre el mismo campo, el problema no era el modelo.


    Por dónde empezar mañana

    Coge el punto de tu agente donde hoy salta una excepción de validación. Uno solo.

    Añade tres cosas: extrae el error real (error.cause, no error.message), formatéalo a ruta y mensaje, y devuélvelo como turno nuevo con techo de dos intentos y maxRetries: 0. Loguea cuántas veces entra en la segunda vuelta y cuántas sale con éxito.

    Ese ratio es el diagnóstico del diagnóstico. Si entra a menudo y se corrige, tienes un schema mejorable pero un bucle sano. Si entra mucho y no se corrige, tienes un schema imposible: le estás pidiendo al modelo un campo que nadie podría rellenar con el contexto que le das. Y si no entra casi nunca, enhorabuena — tu schema ya hace el trabajo y el bucle es solo la red.

    En Dominicode Labs es donde vamos rodando estos patrones sobre proyectos reales, con las métricas puestas.

    Los sistemas agénticos que aguantan en producción no son los que no se equivocan. Son los que tienen el diagnóstico a mano y saben devolvérselo al modelo antes de despertar a nadie.


    Preguntas frecuentes

    ¿Por qué mi agente no se corrige aunque le paso el error?

    La causa más común es pasar error.message en vez de error.cause. En un NoObjectGeneratedError del Vercel AI SDK, message es un texto genérico que no nombra el campo ni el tipo esperado; el diagnóstico útil vive en error.cause —el ZodError con sus issues— y la salida cruda del modelo en error.text. Con solo message, el bucle gasta llamadas sin darle al modelo nada con lo que corregir.

    ¿En qué se diferencia el self-healing code de un retry con backoff?

    En qué cambia entre un intento y el siguiente. El backoff reintenta la misma petición esperando que se recupere algo externo —red, proveedor, rate limit— y por eso funciona con fallos transitorios. El self-healing modifica la entrada: añade al contexto el diagnóstico que provocó el fallo. Ante un error de schema, el backoff solo repite el mismo error más despacio.

    ¿No reintenta ya generateObject por su cuenta con maxRetries?

    No de esta forma, y conviene ponerlo a 0. La opción maxRetries del AI SDK vale 2 por defecto y cubre fallos de transporte: errores de red y respuestas de API reintentables (408, 409, 429, 5xx) con backoff exponencial. Un fallo de validación de schema no entra ahí, se propaga como NoObjectGeneratedError. Si lo dejas por defecto, cada vuelta de tu bucle puede disparar hasta tres peticiones HTTP.

    ¿Cuántos intentos debería permitir?

    Dos totales: la llamada inicial y una corrección. Con el error exacto delante, un modelo actual corrige los fallos de forma en el primer reintento o no los corrige. Un tercero rara vez cambia el resultado y multiplica coste y latencia. Y cuenta intentos, no reintentos: un bucle i <= 2 da una sola corrección, y es donde más gente se equivoca al implementarlo.

    ¿Se puede hacer self-healing solo con el compilador, sin Zod?

    Sí, y son capas complementarias. tsc --noEmit cubre el código que el agente escribe; Zod cubre la salida estructurada que el modelo devuelve. Si tu agente genera archivos, el compilador es tu oráculo principal. Si devuelve objetos que tu aplicación consume, lo es Zod. Muchos agentes acaban usando los dos en puntos distintos del flujo.

    ¿Sirve para errores de lógica o solo para errores de tipos?

    Sirve para los de lógica, pero cambiando el oráculo: ahí el que dictamina es el test runner, y el diagnóstico que reinyectas es la aserción fallida con su diff esperado/recibido. La diferencia práctica es el riesgo. Un error de tipos tiene una única corrección posible; un test rojo admite varias, y alguna rompe otra cosa. Por eso en esta capa se ejecuta la suite completa antes de aceptar el parche.

    ¿Qué hago si el agente rompe otro test al arreglar el primero?

    Tratarlo como un fallo del intento, no como un éxito parcial. Si el criterio de aceptación es solo el test que fallaba, el bucle acepta parches que degradan el código. El criterio tiene que ser la suite entera en verde; si el parche pone A en verde y B en rojo, se descarta y se consume intento. Con techo de dos, eso normalmente significa escalar — que es la respuesta correcta.

    ¿Es seguro autocorregir una operación que ya escribió en base de datos?

    No. Si el intento fallido tuvo efectos externos, el reintento los duplica. El bucle es seguro mientras la operación no haya salido de tu proceso: valida primero, ejecuta después. Si el efecto ya ocurrió, lo que necesitas es idempotencia o compensación, no autocorrección.


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

  • Programar con IA: el cuello de botella es verificar, no escribir

    Programar con IA: el cuello de botella es verificar, no escribir

    Hace un año le pedía una feature al agente y me devolvía doscientas líneas.

    Hoy me devuelve ochocientas, y tarda menos.

    Mi ritmo de entrega es exactamente el mismo.

    El cuello de botella dejó de ser escribir código. Ahora es verificarlo — y eso lo sigo haciendo yo, a mano.

    Durante meses lo achaqué a cosas mías: mala semana, tarea rara, repo complicado. Hasta que hice el cálculo aburrido de cuánto tiempo pasaba generando y cuánto verificando código generado por IA. Generar: cuatro minutos. Verificar: casi dos horas.

    Cuadruplicar la velocidad de los cuatro minutos no me iba a devolver ni un minuto de las dos horas.

    Goldratt lo dejó escrito en La Meta, en 1984, y sigue siendo la frase más útil que conozco para esto: una hora ganada donde no está el cuello de botella es un espejismo. Toda la industria lleva tres años ganando horas justo ahí.

    El cuello de botella se movió y nadie avisó

    El cuello de botella del desarrollo con IA se movió de escribir código a verificarlo. Los datos van en la misma dirección que la anécdota.

    El informe DORA de 2024 midió algo que a mucha gente le sentó fatal: por cada 25% de aumento en la adopción de IA en una organización, el throughput de entrega bajaba un 1,5% estimado y la estabilidad un 7,2%. Más código, menos entrega, y bastante menos tranquilidad.

    Lo interesante es lo que pasó después. En la edición de 2025 el throughput ya sale positivo: aprendimos a mover el código generado hasta producción sin atascarnos tanto. Pero la relación con la estabilidad sigue siendo negativa.

    Traducido: arreglamos la velocidad. No arreglamos la confianza.

    Y no es raro, porque entre el código que sale del agente y producción hay un paso que no ha mejorado nada en tres años: alguien tiene que decidir si eso es correcto. Ese alguien eres tú, con el mismo cerebro de 2019 y menos horas de sueño.

    Es el único componente del sistema que no escala, y es el que recibe todo lo que los demás producen más rápido.

    Por eso un modelo mejor no lo arregla. Un modelo mejor te da código correcto más a menudo — te sube el porcentaje de aciertos, no te quita la obligación de comprobar. Y como no sabes de antemano cuál de las ochocientas líneas es la que falla, sigues teniendo que mirarlas todas.

    Un modelo con un 97% de acierto sobre ochocientas líneas te deja veinticuatro líneas malas escondidas y ninguna pista de dónde.

    Qué es la Revisión por Contrato

    Revisión por Contrato es un método para verificar código generado por IA sin leerlo entero, moviendo la verificación de tu cabeza a procesos que se ejecutan solos. Son tres piezas, en este orden:

    Contrato. Lo que se construye, escrito de forma que una máquina pueda rechazarlo. No "el endpoint debe ser rápido", sino "p95 por debajo de 200 ms en el test de carga del CI". Vive en dos sitios: el AGENTS.md para lo permanente del repositorio, la spec para lo que nace y muere con esta tarea.

    Carril. Por dónde el agente no puede salirse. Qué ficheros no toca, qué no instala, qué asserts existentes no modifica. Porque la mayoría de los desastres de un agente no son de lógica: son de alcance.

    Veredicto. Quién dice que está bien. Un conjunto de comandos con dos estados posibles y ninguno más: build, tipos, lint, tests y — la que casi nadie tiene — un comando detrás de cada criterio de aceptación.

    Pieza Qué declara Quién la hace cumplir
    Contrato Lo que se construye, en cláusulas que una máquina puede rechazar AGENTS.md para lo permanente del repo + la spec de la tarea
    Carril Por dónde el agente no puede salirse: qué no toca, qué no instala, qué asserts no modifica Límites de escritura declarados por escrito
    Veredicto Si está bien o no, en dos estados y ninguno más El harness de verificación: build, tipos, lint, tests y un comando por criterio de aceptación

    Lo que revisas después no es el diff. Es el veredicto y el contrato.

    Cómo se monta cada pieza, con el AGENTS.md entero y los dos bucles de verificación, lo tengo desarrollado en el post del método. Aquí me interesa lo otro: por qué esto funciona.

    En qué se basa el método para verificar código generado por IA

    Nada de esto lo he inventado yo. Son tres ideas viejas que la IA no rompió, solo hizo urgentes.

    1. Una especificación que no puede fallar es un comentario

    En 1986 Bertrand Meyer metió los contratos dentro del lenguaje Eiffel: precondiciones, postcondiciones, invariantes. La idea era que la especificación de una función dejara de vivir en un documento y pasara a vivir en el código, con una propiedad nueva — que el programa revienta cuando se incumple.

    Esa es la línea que separa documentar de obligar. La misma que ya conoces entre un README que pide formatear antes de commitear y un hook que no te deja commitear sin formatear.

    Tu spec para el agente está casi entera del lado equivocado de esa línea. Coge la última que escribiste y cuenta cuántas de sus líneas podría rechazar una máquina. Suelen ser tres de veinte. Las otras diecisiete son intenciones: orientan al modelo, no rechazan nada, y por eso tu spec no te frenó ni un bug.

    Un contrato no es una spec mejor escrita. Es una spec que puede decir que no.

    Esa conversión —coger tu spec y pasar sus líneas a cláusulas que se pueden incumplir— la tienes entera en el ebook gratuito de Revisión por Contrato, con el código puesto para que lo copies.

    2. Quien produce no puede ser quien juzga

    En cualquier oficio donde el resultado importa, esto es tan obvio que ni se discute. El que lleva las cuentas no es el que las audita. El que escribe el paper no es quien lo revisa.

    En tu repo lleva meses pasando lo contrario y no lo has mirado: el agente tiene permiso de escritura sobre la cosa que lo verifica.

    Los tests son ficheros. El linter se configura con un fichero. El workflow de CI es un fichero. Todo está dentro de su radio de acción. Y cuando le pides que los tests pasen, tocar el assert es un camino perfectamente válido hacia lo que pediste — más corto que arreglar el código, de hecho.

    No hace trampas. Cumple el objetivo por la ruta más barata, que es exactamente para lo que está optimizado.

    Si el verificado puede editar al verificador, no tienes verificación. Tienes teatro. Y de ahí sale el carril, que no es una regla de buenas maneras: es la separación de poderes de tu repositorio.

    3. Llevamos cuarenta años sacando comprobaciones de la cabeza del humano

    El compilador quitó una clase entera de errores que antes se cazaban leyendo. El type checker quitó otra. El linter quitó las discusiones de estilo de las revisiones de código. El CI quitó el "en mi máquina funciona".

    Cada salto de productividad real de esta profesión ha sido el mismo movimiento: coger una comprobación que hacía una persona cansada y dársela a un proceso que no se cansa.

    La Revisión por Contrato no es una idea nueva. Es ese mismo movimiento aplicado al último sitio donde todavía no lo habíamos hecho: comprobar que el código generado hace lo que se pidió.

    Y aquí está el error de época, el que veo en casi todos los equipos: creer que la IA también puede hacer esa parte. Poner un segundo agente a revisar al primero se siente productivo, pero un modelo probabilístico revisando a otro modelo probabilístico no te da un veredicto — te da una segunda opinión, más larga y con la misma naturaleza. La IA genera. Verificar lo hace algo determinista, que sale con código cero o distinto de cero.

    Por qué esto sí resuelve el cuello de botella

    Tres razones, y la tercera es la que me convenció.

    Tu revisión deja de escalar con el tamaño del diff. Hoy revisas ochocientas líneas porque el agente escribió ochocientas. Con contrato revisas cuarenta líneas de contrato y un veredicto, y esas cuarenta líneas no crecen cuando el agente escribe el doble. Rompes el vínculo entre lo que produce la máquina y lo que consume tu atención, que es literalmente la definición de desatascar un cuello de botella.

    El error cambia de sitio y de dueño. Un fallo que detecta el bucle corto a los veintiocho segundos lo arregla el agente, casi siempre solo, y no te enteras. El mismo fallo dentro de una pull request cuesta tu contexto, tu tarde y a veces tu fin de semana. No es que haya menos errores: es que dejan de ser tuyos.

    Y es la única pieza que mejora cuando el modelo mejora. Esta es la buena. Sin verificación, un agente el doble de rápido te dobla la cola de revisión — la mejora del proveedor se convierte en trabajo tuyo. Con verificación, un agente el doble de rápido entrega el doble, porque el harness absorbe el aumento sin pedirte más atención. Es la diferencia entre que los próximos dos años de avances te lleguen como regalo o como factura.

    Lo que no resuelve

    Sería raro que te vendiera esto sin decirte dónde se acaba.

    El contrato comprueba que el código hace lo que pediste. No tiene ni idea de si pediste lo correcto.

    Tampoco te dice si el nombre de ese servicio encaja con el lenguaje del dominio, si la solución es proporcionada al problema, o si acabas de meter la tercera forma distinta de hacer lo mismo en el mismo repo. Eso sigue siendo trabajo humano y lo va a seguir siendo.

    Lo cual, si lo piensas, es una noticia excelente. Ese trabajo — decidir qué se construye y si tiene sentido — siempre fue el nuestro. Lo que nos habíamos autoimpuesto era el otro: leer ochocientas líneas buscando un null.

    La prueba de una línea

    Si quieres saber en treinta segundos si tienes un contrato o un deseo, coge el último criterio de aceptación que escribiste y hazte esta pregunta:

    ¿Puedo escribir algo que compruebe esto sin mí?

    Si la respuesta es sí, es una cláusula. Si es no, es una intención. Y tu tiempo de revisión es, casi exactamente, la suma de tus intenciones.

    Empieza por ahí. Una sola línea de una sola spec, convertida en un comando que devuelve cero o distinto de cero. Es media hora y ya lo notas en la siguiente tarea.

    Cuando quieras el sistema completo — las cinco secciones del AGENTS.md, los dos bucles y los límites, con el porqué de cada línea — está en el ebook gratuito de Revisión por Contrato. Treinta páginas, sin coste.

    Si lo que quieres es montarlo entero de una sentada, sobre un Issue de verdad y hasta la pull request verificada, ese camino completo es el workshop de SDD + Agentic Engineering. Tres horas on-demand, nueve módulos, y sales con tu harness montado en tu repo, no con apuntes.

    Y si prefieres verlo antes de leer nada, esto lo monto en directo cada cierto tiempo: webinar de Revisión por Contrato. Cincuenta y cinco minutos sobre una feature real — el agente entrega, la verificación falla, y en pantalla se ve qué cláusula rompió. Gratis, y la próxima fecha está en la página.

    La parte de cómo se escribe la spec de cada tarea, con más profundidad, la tienes en el libro de Spec-Driven Development.

    El cuello de botella no se mueve solo. Pero se mueve.

    Preguntas frecuentes

    ¿Qué es exactamente la Revisión por Contrato?

    Un método para verificar código generado por IA sin leerlo entero. Tiene tres piezas: un contrato con cláusulas que una máquina puede rechazar (no "debe ser rápido", sino "p95 < 200 ms en el CI"), un carril que declara por escrito dónde el agente no puede escribir, y un veredicto emitido por comandos ejecutables con dos estados posibles. Lo que revisa la persona después es el veredicto y el contrato, no el diff completo.

    ¿Por qué un modelo mejor no te ahorra verificar código generado por IA?

    Porque un modelo mejor sube el porcentaje de aciertos, no elimina la obligación de comprobar. Con un 97% de acierto sobre ochocientas líneas te quedan veinticuatro líneas malas y ninguna indicación de cuáles son, así que sigues teniendo que revisarlas todas.

    ¿No puedo poner otro agente a revisar el código del primero?

    Ayuda como segunda opinión, no como veredicto. Un modelo probabilístico revisando a otro modelo probabilístico produce texto plausible, no un resultado binario y reproducible. La verificación tiene que apoyarse en algo determinista — build, tipos, lint, tests, criterios de aceptación con un comando detrás — precisamente porque no cambia según cómo venga el día.

    ¿Qué relación tiene la Revisión por Contrato con Design by Contract?

    Es la misma idea de Bertrand Meyer (Eiffel, 1986) sacada de la función y aplicada al agente. Design by Contract mete precondiciones, postcondiciones e invariantes dentro del lenguaje para que el programa reviente cuando se incumplen. La Revisión por Contrato hace lo mismo un nivel por encima: escribe los criterios de aceptación de la tarea en cláusulas que un comando puede rechazar, para que el fallo lo cace el harness de verificación y no tus ojos a las once de la noche.


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

  • Diseñar schemas Zod para LLM: tu schema ya es el prompt

    Diseñar schemas Zod para LLM: tu schema ya es el prompt

    Hace unas semanas revisé el pipeline de extracción de facturas de un cliente. Fallaba en uno de cada seis documentos.

    El equipo ya había subido los reintentos a tres, puesto temperature: 0 y cambiado a un modelo más caro. Seguía fallando.

    Miré el schema de Zod: 31 campos, ninguno con .describe(), ocho z.string() donde solo cabían cuatro valores posibles y cuatro .optional() colocados ahí porque "a veces la factura no lo trae".

    El problema no era el modelo. Era el schema. Diseñar schemas Zod para LLM no consiste en describir la forma de tus datos: consiste en escribir instrucciones que el modelo lee antes de responder.

    Y esa es la parte que casi nadie aprovecha.


    El schema de Zod no espera al final: se envía al LLM dentro del prompt

    Cuando usas structured outputs o tool calling, tu schema de Zod no se queda esperando en el servidor a que llegue el JSON. Se convierte a JSON Schema y se envía al modelo en la misma petición.

    En Zod 4 puedes ver exactamente lo que sale de tu código:

    import * as z from "zod";
    
    const Factura = z.object({
      esValida: z.boolean(),
      tipo: z.string(),
      importe: z.number(),
    });
    
    console.log(z.toJSONSchema(Factura));
    

    Eso es literalmente lo que viaja. Y en el caso de Anthropic la documentación del system prompt de tool use lo enseña sin rodeos: cuando llamas a la API con el parámetro tools, el sistema construye un system prompt que incluye tus definiciones tal cual.

    In this environment you have access to a set of tools you can use to answer the user's question.
    ...
    Here are the functions available in JSONSchema format:
    {{ TOOL DEFINITIONS IN JSON SCHEMA }}
    

    Tus nombres de campo, tus tipos, tus descripciones: todo eso son tokens de entrada que el modelo lee antes de generar el primer carácter. Es la misma idea que ya expliqué al hablar de function calling tipado en TypeScript, pero llevada al extremo.

    Si el schema es texto en el prompt, entonces un schema mal escrito es un prompt mal escrito. Y no hay reintento que arregle eso.


    z.string() es un campo abierto. z.enum() es una pregunta cerrada

    Cambiar z.string() por z.enum() es el ajuste con mejor relación esfuerzo/resultado de toda la lista: z.string() deja el espacio de respuesta abierto y z.enum() lo cierra a una lista finita de valores. Es lo primero que toco cuando un pipeline de extracción falla.

    // El modelo puede escribir lo que le dé la gana
    tipoDocumento: z.string(),
    
    // El modelo solo puede elegir
    tipoDocumento: z.enum(["factura", "abono", "recibo", "presupuesto"]),
    

    La diferencia en el JSON Schema generado es esta:

    { "type": "string", "enum": ["factura", "abono", "recibo", "presupuesto"] }
    

    Con z.string() le pides al modelo que invente una etiqueta. Va a devolver "Factura", "FACTURA", "factura simplificada" y "invoice" según el día. Tu Zod lo aceptará todo, porque son strings válidos, y el error aparecerá tres capas más abajo cuando alguien haga un switch.

    Con z.enum() el espacio de respuesta está cerrado. Además, cuando el proveedor aplica el modo estricto, el enum se traduce en una restricción real de decodificación: el modelo no puede emitir un valor fuera de la lista.

    Regla práctica: si en tu cabeza el campo tiene una lista de valores, escríbela en el schema. Si te da pereza escribirla, es que tampoco la tenías clara tú.


    .describe(): el campo que casi nadie usa y que sí llega al modelo

    .describe() es el método de Zod que más impacto tiene en la precisión de un LLM y el que casi nadie usa: el texto que le pasas acaba en la clave description del JSON Schema que recibe el modelo, no se queda en tu editor.

    fechaVencimientoISO: z
      .string()
      .describe(
        "Fecha límite de pago en formato ISO 8601 (YYYY-MM-DD). " +
        "Es la fecha de vencimiento, NO la de emisión. " +
        "Si el documento dice 'pago a 30 días', súmalos a la fecha de emisión."
      ),
    

    No es decorativo. Compruébalo con z.toJSONSchema():

    {
      "type": "string",
      "description": "Fecha límite de pago en formato ISO 8601 (YYYY-MM-DD). Es la fecha de vencimiento, NO la de emisión. Si el documento dice 'pago a 30 días', súmalos a la fecha de emisión."
    }
    

    Ese description viaja con el schema. En Zod 4, .describe("texto") es equivalente a .meta({ description: "texto" }) y todos los metadatos se copian al JSON Schema resultante.

    La documentación de Anthropic sobre definición de herramientas es tajante al respecto: "Provide extremely detailed descriptions. This is by far the most important factor in tool performance". No es un detalle de estilo. Es el sitio donde metes las reglas de negocio que el nombre del campo no puede expresar.

    Dos avisos prácticos:

    1. La documentación del Vercel AI SDK recomienda encadenar .describe() o .meta() al final de la cadena, porque la mayoría de métodos de Zod devuelven una instancia nueva que no hereda los metadatos. En mis pruebas con z.toJSONSchema() y Zod 4.5.4 (agosto de 2026) la descripción sobrevivía también antes de .optional(), pero son dos caminos de código distintos y seguir la recomendación no cuesta nada.
    2. Cada descripción son tokens que pagas en cada llamada. Describe los campos ambiguos, no los obvios: nombreCliente no necesita párrafo.

    Si quieres dominar la parte de Zod que no es "poner z.string() y seguir", en mi curso de Zod para validación y transformación de datos en TypeScript trabajo esto con schemas reales de producción.


    .optional() es un agujero negro. Dale una salida explícita

    .optional() es el error que más alucinaciones fabrica en un schema pensado para un LLM: saca el campo de required sin dejar ninguna señal de cuándo debe omitirse, y el modelo rellena el hueco.

    Piensa qué ve el modelo cuando marcas un campo como opcional. Este schema:

    z.object({
      importeEUR: z.number().nullable(),
      nota: z.string().optional(),
    });
    

    produce esto:

    {
      "type": "object",
      "properties": {
        "importeEUR": { "type": ["number", "null"] },
        "nota": { "type": "string" }
      },
      "required": ["importeEUR"],
      "additionalProperties": false
    }
    

    Fíjate en nota. Desaparece de required y no queda ninguna otra señal. El modelo no recibe ninguna pista sobre cuándo debe omitirlo ni qué significa su ausencia. Tú sabes que "ausente" quiere decir "no aplica", pero eso está en tu cabeza, no en el prompt.

    importeEUR, en cambio, sigue siendo obligatorio y declara null como valor legítimo. El modelo tiene un camino explícito para decir "esto no está".

    Esto además encaja con cómo funcionan los structured outputs estrictos de OpenAI, donde todos los campos deben ir en required y la forma documentada de emular un opcional es un tipo unión con null manteniendo el campo obligatorio.

    Y hay una versión todavía mejor cuando el "no lo sé" tiene matices:

    // Mal: el modelo se inventa una fecha para rellenar el hueco
    fechaVencimientoISO: z.string(),
    
    // Regular: puede omitirlo, pero no sabe cuándo
    fechaVencimientoISO: z.string().optional(),
    
    // Bien: el "no sé" es una respuesta válida y tipada
    vencimiento: z.discriminatedUnion("estado", [
      z.object({ estado: z.literal("presente"), fechaISO: z.string() }),
      z.object({ estado: z.literal("no_aplica") }),
      z.object({ estado: z.literal("ilegible") }),
    ]),
    

    Un modelo obligado a rellenar un campo que no puede saber rellena igual. Eso no es un bug del modelo, es la consecuencia directa de por qué la IA se inventa cosas: si el schema no ofrece una salida honesta, la salida más probable es una plausible. Diséñale la puerta de "no lo sé" y la usará.


    Uniones discriminadas en Zod: dale un mapa al modelo, no un test de opción múltiple

    Con z.union(), el JSON Schema resultante es un anyOf de objetos sin nada que los distinga:

    // salida recortada de z.toJSONSchema()
    { "anyOf": [ { "properties": { "importeEUR": ... } }, { "properties": { "motivo": ... } } ] }
    

    El modelo tiene que deducir cuál encaja comparando formas. Es una decisión difusa.

    Con z.discriminatedUnion() cambia la estructura:

    const Movimiento = z.discriminatedUnion("tipo", [
      z.object({ tipo: z.literal("pago"), importeEUR: z.number() }),
      z.object({ tipo: z.literal("reembolso"), motivo: z.string() }),
    ]);
    

    Sale un oneOf en el que cada rama lleva { "type": "string", "const": "pago" } en el discriminador. El modelo primero elige una etiqueta —decisión de un token, con opciones cerradas— y a partir de ahí la forma del resto del objeto queda determinada.

    Es la misma razón por la que las uniones discriminadas nos gustan en TypeScript: convierten una inferencia estructural en una decisión explícita. Solo que aquí quien se beneficia del narrowing no es el compilador, es el modelo.

    Un aviso importante antes de que lo copies: en el modo estricto de OpenAI la raíz del schema tiene que ser un objeto, y la documentación es explícita en que un objeto raíz no puede ser del tipo anyOf. Así que no mandes la unión suelta como en el ejemplo de arriba: anídala dentro de un objeto raíz, como el campo vencimiento de la sección anterior.

    Y un detalle más: z.toJSONSchema() emite oneOf para las uniones discriminadas, mientras que la lista de tipos soportados de OpenAI habla de anyOf. Si vas contra structured outputs, pasa por el helper de su propio SDK (zodResponseFormat de openai/helpers/zod) en lugar de por z.toJSONSchema() directo. Contra tool use de Anthropic no tienes esta restricción.


    El orden de los campos no es cosmética

    Un LLM genera tokens en orden. Si el primer campo de tu objeto es la conclusión, la conclusión se escribe antes de que exista ningún razonamiento en el contexto.

    Y el orden lo pones tú. La documentación de structured outputs es explícita: la salida se produce en el mismo orden en que están las claves del schema que envías. Si quieres cambiar el orden, cambias el schema.

    // Mal: decide primero y justifica después
    const TriajeMal = z.object({
      prioridad: z.enum(["alta", "media", "baja"]),
      evidencia: z.string(),
    });
    
    // Bien: reúne evidencia, luego concluye
    const TriajeBien = z.object({
      evidencia: z
        .string()
        .describe("Cita literal del ticket que justifica la prioridad."),
      senalesRiesgo: z.array(z.enum(["caida_servicio", "perdida_datos", "cliente_enterprise"])),
      prioridad: z.enum(["alta", "media", "baja"]),
    });
    

    No es una teoría mía. El ejemplo canónico de razonamiento matemático de la propia documentación de OpenAI pone steps antes de final_answer. El schema es la plantilla del razonamiento, no solo del resultado.

    Lo mismo aplica a los nombres. date no dice nada; fechaVencimientoISO dice qué fecha es y en qué formato la quieres. El nombre del campo es contexto gratis: no lo desperdicies en abreviaturas.

    Y sobre la profundidad: los schemas planos aciertan más. El modo estricto de OpenAI admite hasta 5.000 propiedades por schema, así que el límite técnico no te va a frenar nunca. El que importa es otro: mucho antes de acercarte a esa cifra ya notarás que un objeto de cuatro niveles produce más fallos que dos llamadas con dos schemas planos.


    Dónde termina el diseño del schema y empieza la validación con Zod

    Nada de esto elimina la validación. Un schema bien diseñado reduce los fallos en origen; no los lleva a cero, y sigues necesitando safeParse, reintentos y logging.

    Esa es exactamente la frontera: este post va de lo que ocurre antes de la llamada. Lo que ocurre después —parseo seguro, limpieza defensiva, reintentos con contexto del error— lo tienes desarrollado en cómo tipar las respuestas de una LLM con Zod y TypeScript.

    Ni siquiera tienen que ser el mismo schema. Manda al modelo uno plano y con enums, y transfórmalo después a tu modelo de dominio con las utilidades genéricas de tus wrappers.


    Resumen: qué lee el modelo en cada caso

    Lo que escribes en Zod Lo que lee el modelo Cuándo usarlo
    z.string() campo de texto libre, sin restricción solo texto genuinamente libre
    z.enum([...]) "enum": ["a","b"] — lista cerrada cualquier campo con valores finitos
    .describe("...") "description": "..." — instrucción del campo campos ambiguos o con regla de negocio
    .optional() el campo desaparece de required, sin más señal casi nunca en schemas para LLM
    .nullable() "type": ["string","null"] y sigue en required cuando "no hay dato" es respuesta válida
    z.discriminatedUnion() oneOf con const en el discriminador cuando el "no lo sé" tiene matices

    Qué puedes cambiar hoy

    Abre el schema que tengas en producción y haz estas cinco pasadas. Te llevará veinte minutos:

    1. Ejecuta z.toJSONSchema(tuSchema) y lee la salida. Ese texto es tu prompt. Si te resulta ambiguo a ti, imagina al modelo.
    2. Convierte a z.enum() todo z.string() que tenga una lista finita de valores.
    3. Añade .describe() solo a los campos ambiguos, con la regla de negocio y el formato exacto.
    4. Sustituye cada .optional() por .nullable() o por una rama explícita de "desconocido" en una unión discriminada.
    5. Mueve la conclusión al final y pon delante los campos de evidencia.

    En el pipeline de facturas del principio no hizo falta tocar el modelo ni subir más los reintentos: el campo que más fallaba era una fecha obligatoria que el documento a veces no traía, y pasó de inventarse valores a declarar ilegible en cuanto dejó de ser un string a secas.

    Si además estás montando el pipeline entero —schema, llamada, validación y reintentos— eso es justo lo que construimos paso a paso en el curso Construye con IA: de la idea al producto, y en Dominicode Labs revisamos schemas reales de proyectos de la comunidad.

    La conclusión que quiero que te lleves es una sola: deja de tratar el schema como un portero que revisa la salida del modelo y empieza a tratarlo como la última instrucción que el modelo lee antes de contestar. Cambia el diseño y dejarás de necesitar tantos reintentos.


    Preguntas frecuentes

    ¿El modelo lee de verdad el .describe() de mis campos?

    Sí. .describe() se traduce a la clave description del JSON Schema, y ese JSON Schema es lo que se envía al proveedor junto con la petición. En el caso de Anthropic, la documentación muestra que las definiciones de herramientas se insertan en el system prompt en formato JSON Schema. Puedes comprobar exactamente qué se envía ejecutando z.toJSONSchema() sobre tu schema.

    ¿Usar .optional() está mal siempre?

    No, pero casi nunca es lo que quieres cuando el schema va a un modelo. .optional() hace que el campo desaparezca de required sin dejar ninguna señal sobre cuándo omitirlo. Con .nullable() el campo sigue siendo obligatorio y null es una respuesta explícita. Además, el modo estricto de structured outputs de OpenAI exige que todos los campos estén en required y documenta la unión con null como la forma de emular un opcional.

    ¿Structured Outputs elige el valor correcto o solo el formato correcto?

    Garantiza que la estructura encaje con el schema, no que el contenido sea correcto. Un modelo puede devolver una fecha con formato válido y valor inventado, o elegir el enum equivocado. Diseñar bien el schema mejora el acierto semántico; validar después sigue siendo obligatorio.

    ¿Cuántos campos debería tener un schema para un LLM?

    Menos de los que crees. El modo estricto de OpenAI admite hasta 5.000 propiedades por schema, así que el límite que importa no es el técnico sino el de acierto: la degradación empieza muchísimo antes. Si tu schema pasa de veinte campos o de dos niveles, casi siempre sale mejor partirlo en dos llamadas con schemas planos que insistir en una sola extracción gigante.

    ¿Esto aplica igual con el Vercel AI SDK?

    Sí. generateObject y las definiciones de tools convierten internamente tu schema de Zod a JSON Schema con el helper zodSchema, así que las mismas reglas de diseño aplican. La documentación del SDK recomienda además encadenar .describe() o .meta() al final de la cadena para asegurar que los metadatos acaben en el JSON Schema generado.


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

  • Local-first con PGlite: Postgres en el navegador, ¿te toca?

    Local-first con PGlite: Postgres en el navegador, ¿te toca?

    Abro una app de notas con IA, escribo una pregunta y espero.

    No espero al modelo. Eso lo entiendo: el modelo piensa. Espero a que la app viaje al servidor para leer tres filas de mi propio historial, las traiga de vuelta y solo entonces monte el prompt. Doscientos milisegundos de red para leer datos que ya estaban en mi portátil.

    Ese es el patrón por defecto de casi todas las apps de IA que reviso. La arquitectura local-first le da la vuelta: la base de datos vive en el dispositivo del usuario, la app lee de ahí a velocidad de memoria, y el servidor pasa de ser la fuente de toda verdad a ser un destino de sincronización.

    Aquí va la tesis. Mover la base de datos al cliente no es una optimización: es un cambio de arquitectura que reparte de otra forma la latencia, el coste de tokens y la privacidad, y a cambio te devuelve tres problemas que en el servidor no tenías — una sola conexión, migraciones que corren en máquinas que no controlas, y un camino de escritura hacia el servidor que nadie te da hecho.

    Si terminas de leer sabiendo si te toca o no, el post ha cumplido.

    Qué es la arquitectura local-first

    Local-first es una arquitectura en la que la base de datos vive en el dispositivo del usuario: la aplicación lee y escribe siempre contra esa copia local, a velocidad de memoria, y el servidor deja de ser la fuente de toda verdad para convertirse en un destino de sincronización. La app funciona sin red por defecto, no como caso degradado.

    No es lo mismo que cachear. Una caché es una copia que aceleras y que puedes tirar; en local-first la copia local es donde de verdad ocurre todo, y la red es un detalle de implementación.

    Qué es PGlite y por qué no es "otra base de datos en el navegador"

    PGlite es Postgres compilado a WebAssembly y empaquetado como librería de TypeScript. Corre en el navegador, en Node.js y en Bun, sin dependencias externas y sin proceso servidor.

    La distinción que importa está en su propia documentación: "Unlike previous 'Postgres in the browser' projects, PGlite does not use a Linux virtual machine – it is simply Postgres in WASM."

    Eso no es marketing, es la decisión arquitectónica del proyecto. Los intentos anteriores emulaban una máquina Linux entera para arrancar encima un Postgres normal: pagabas el peso de un sistema operativo simulado para ejecutar un SELECT. PGlite elimina esa capa.

    Los números, para que no tengas que buscarlos:

    • PGlite pesa menos de 3 MB gzipped y corre Postgres compilado a WASM, sin máquina virtual Linux.
    • Persiste en memoria (efímero), en IndexedDB en el navegador, o en el sistema de ficheros en Node y Bun.
    • La versión actual de @electric-sql/pglite es la 0.5.8, publicada el 26 de agosto de 2026, y el plugin de sincronización @electric-sql/pglite-sync va por la 0.6.9.

    Retén ese "0.x". Vuelvo a ello más abajo.

    Qué gana una app de IA con arquitectura local-first

    Una app de IA gana tres cosas al pasar a local-first: latencia de microsegundos en lugar de un round-trip de red, menos tokens por turno porque filtras el contexto donde ya están los datos, y una garantía de privacidad real porque el historial no tiene por qué salir del dispositivo. Ninguno de los tres es "va más rápido" a secas.

    Latencia: microsegundos frente a un round-trip de red

    Los benchmarks oficiales, medidos en un MacBook Air M2 con PGlite en memoria, dan estos tiempos de ida y vuelta por operación: insert de una fila pequeña en 0,058 ms, select en 0,088 ms, update en 0,073 ms y delete en 0,145 ms.

    Compáralo con los 50-200 ms de una llamada HTTP a tu API y el cambio deja de ser cuantitativo: pasa a ser de diseño.

    Cuando leer cuesta microsegundos dejas de diseñar para minimizar consultas. Se acabó cachear tres pantallas por delante, montar endpoints agregados y pintar skeletons.

    Coste de tokens: RAG en el navegador con pgvector

    Este es el eje que más se subestima, y el que de verdad justifica local-first en una app de IA.

    El patrón habitual del chatbot es incómodo cuando lo miras de frente: mandas todo el historial al servidor en cada turno para que el backend reconstruya un contexto que ya estaba entero en el dispositivo del usuario. Pagas tokens por transportar información que no había salido de casa.

    Con un Postgres real en el cliente filtras el contexto en local —con SQL de verdad, joins y filtros por fecha— y mandas al modelo solo lo que importa. Y como PGlite soporta pgvector, ese filtro puede ser búsqueda semántica y no solo un WHERE:

    import { PGlite } from '@electric-sql/pglite'
    import { vector } from '@electric-sql/pglite-pgvector'
    
    const pg = new PGlite({
      extensions: { vector },
    })
    
    await pg.exec('CREATE EXTENSION IF NOT EXISTS vector;')
    

    La consulta por distancia de embeddings corre en el navegador y solo los cinco fragmentos relevantes viajan al modelo:

    SELECT id, content
    FROM chunks
    ORDER BY embedding <-> $1::vector
    LIMIT 5;
    

    Ese <-> es distancia euclídea; si tus embeddings vienen de OpenAI, lo habitual es coseno con <=>. Elige el operador que case con el índice que crees, no el del ejemplo que copiaste.

    Decidir qué entra en el contexto antes de escribir el primer prompt es justo el trabajo que hacemos en el curso de Construye con IA: el prompt no es el sistema, es la última capa.

    Si vienes de montar RAG en servidor, el contraste está en Búsqueda híbrida y embeddings en Supabase — misma técnica, distinto sitio. Y si aún dudas de si tu caso pide RAG, contexto o fine-tuning, esa decisión va antes que esta y la tienes en RAG vs fine-tuning vs contexto.

    Privacidad: los datos no salen del dispositivo

    En una app de notas es un argumento de venta. En salud, legal o finanzas es el requisito que decide si el proyecto existe.

    Con la base de datos en el cliente decides tú qué se sincroniza. Y puedes decidir que nada: si esa tabla no entra en ningún shape, el historial del usuario nunca toca tu infraestructura. Solo sale lo que mandas al modelo, y eso lo controlas con una consulta, no con una política de retención.

    Es la diferencia entre "prometemos que no miramos tus datos" y "no los tenemos".

    Pero ese modo máximo se paga dos secciones más abajo: sin copia en servidor no hay red de seguridad para las migraciones ni multi-dispositivo. Privacidad total y recuperación ante desastres son dos posiciones del mismo mando.

    Cómo queda la arquitectura: PGlite como fuente de verdad local

    PGlite es la fuente de verdad para la UI: la aplicación lee y escribe siempre contra la base local, nunca contra la red. El servidor mantiene su Postgres, que sigue siendo la fuente de verdad del negocio, y las escrituras llegan hasta él por un camino que montas tú. Ahora vuelvo a eso.

    Entre los dos, sincronización basada en shapes. Un shape es un subconjunto de una tabla: no te bajas messages entera, te bajas "los mensajes de las conversaciones de este usuario de los últimos 90 días". El cliente declara qué porción del mundo le interesa y el sync la mantiene al día.

    Y aquí va el matiz que decide presupuestos: ese sync va en una sola dirección. La documentación de pglite-sync no se esconde — "We don't yet support local writes being synced out, or conflict resolution" — y Electric lo remata: hace read-path sync, y no hace write-path sync.

    Traducido: el camino servidor → cliente te lo dan hecho. El camino cliente → servidor lo escribes tú. Cola de escrituras local, reintentos, orden, idempotencia y qué pintas mientras la escritura está en vuelo. Electric documenta cuatro patrones para eso, pero son patrones, no un paquete que instalas.

    Dos límites más de los shapes que conviene saber antes de diseñar: no se pueden sincronizar varios shapes sobre la misma tabla, porque una suscripción necesita poder tirar todos los datos y empezar de cero; y para garantizar consistencia transaccional los datos se agregan en memoria, lo que con shapes muy grandes se nota.

    Encima van las live queries del módulo @electric-sql/pglite/live: registras una consulta y los resultados se actualizan solos cuando cambian los datos, vengan del usuario o del sync. Sin polling y sin invalidación manual de caché.

    Ahí está el efecto secundario grande: desaparece la mitad de tu capa de gestión de estado. El estado del servidor deja de ser algo que cacheas a mano y pasa a ser una tabla que se actualiza. Esa reorganización de responsabilidades la traté en Clean Architecture en Frontend.

    Lo que se te rompe al llevar Postgres al navegador

    Llevar Postgres al navegador te devuelve seis problemas que en el servidor no tenías: una sola conexión, migraciones que corren en dispositivos ajenos, resolución de conflictos sin librería que la resuelva por ti, el peso de arranque, benchmarks que solo valen en memoria y una API todavía en 0.x. Esta es la sección que importa.

    Una sola conexión: el worker no es opcional

    La documentación lo dice sin adornos: "PGlite is single connection only". Y hay un segundo problema encima: si ejecutas PGlite en el hilo principal, bloqueas la UI.

    La solución oficial es el multi-tab worker: una única instancia de PGlite dentro de un Web Worker y una elección de líder que hace de proxy para las peticiones de todas las pestañas abiertas. Cuando la pestaña líder se cierra, se elige otra y se levanta una instancia nueva.

    El worker:

    // my-pglite-worker.js
    import { PGlite } from '@electric-sql/pglite'
    import { worker } from '@electric-sql/pglite/worker'
    
    worker({
      async init() {
        return new PGlite()
      },
    })
    

    Y el cliente:

    import { PGliteWorker } from '@electric-sql/pglite/worker'
    
    const pg = new PGliteWorker(
      new Worker(new URL('./my-pglite-worker.js', import.meta.url), {
        type: 'module',
      }),
    )
    

    Son quince líneas, pero no las trates como boilerplate. Tu base de datos vive ahora detrás de una frontera asíncrona con elección de líder, y eso condiciona cómo pruebas la app y qué ocurre en el segundo en que el usuario cierra la pestaña líder.

    Migraciones de esquema en dispositivos que no controlas

    En el servidor una migración es un evento: la lanzas, corre, se acabó. Hay una base de datos y tú tienes la llave.

    En local-first tienes N versiones del esquema repartidas por dispositivos ajenos. Un usuario abrió la app en marzo y no ha vuelto. Cuando vuelva, su base local está seis migraciones por detrás y esas seis tienen que aplicarse en orden, en su navegador, sin romperse a mitad.

    Esto es lo que se lleva por delante los planes de rollback: no puedes revertir una migración en 8.000 portátiles.

    La consecuencia práctica es que el esquema local evoluciona de forma aditiva casi siempre. Columnas nuevas, no renombradas. Tablas nuevas, no reestructuradas. Y una tabla de versión de esquema desde el día uno, antes de tener usuarios.

    La red de seguridad que sí funciona no es el rollback, es el reset. Si el servidor es la fuente de verdad del negocio, la base local es desechable: ante una migración que no aplica, la borras del dispositivo y vuelves a sincronizar los shapes desde cero. Es feo, tarda y hay que pintarlo bien, pero funciona.

    La letra pequeña: eso solo existe si hay copia en servidor. Si elegiste el modo máximo de privacidad, no hay de dónde resincronizar y cada migración es un disparo único sobre datos irrecuperables. Ahí el esquema aditivo deja de ser buena práctica y pasa a ser la única opción.

    Resolución de conflictos: aquí no hay magia

    Dos dispositivos offline. Los dos editan el mismo registro. Los dos recuperan la red.

    Sí existen librerías que deciden por ti: los CRDT de Yjs, Automerge o Loro convergen sin preguntarte. Pero convergen a una respuesta, no necesariamente a la que tu negocio considera correcta. Un CRDT te garantiza que dos dispositivos acaban iguales; no te garantiza que el saldo resultante sea el que el usuario esperaba. Esa decisión no la delegas.

    Tus opciones reales son tres: last write wins y perder ediciones en silencio, guardar ambas versiones y preguntar al usuario, o modelar los datos para que los conflictos sean estructuralmente imposibles — append-only, eventos en vez de estado, campos con un único dueño. La tercera es la buena, y es una decisión de modelado que tomas antes de escribir código.

    Su contrapartida: una base append-only crece sin techo, y eso choca con la pregunta que cierra este post — si los datos caben en el dispositivo. Compacta por antigüedad o materializa el estado cada N eventos, desde el principio.

    Un detalle que se olvida: lo que llega del sync es entrada externa y merece validarse como el body de una API. Un payload con un campo cambiado por una versión antigua del cliente puede corromper la base local del usuario, y ahí ya no tienes acceso para arreglarlo. Es el escenario exacto para el que trabajamos schemas en el curso de Zod: validar en la frontera, no confiar en el tipo.

    3 MB antes de que el usuario vea nada

    PGlite pesa menos de 3 MB gzipped, y es un coste de arranque real que pagas en el primer render.

    En una app que el usuario abre a diario se amortiza en el primer uso. En una landing con formulario es inaceptable. Cárgalo diferido, después del primer pintado, con un estado de "preparando" que no sea una pantalla en blanco.

    Los 0,058 ms son en memoria

    Aquí es donde muchos posts sobre PGlite venden humo, así que lo digo claro: los benchmarks de PGlite están medidos en memoria; en cuanto persistes a IndexedDB, la foto cambia.

    La propia documentación lo reconoce: "An fsync or flush to the underlying storage can be quite slow, particularly in the browser with IndexedDB for PGlite, or OPFS for wa-sqlite."

    Y es igual de honesta comparándose con SQLite en WASM: "wa-sqlite is faster than PGlite when run purely in memory", aunque "For single row CRUD inserts and updates, PGlite is faster then wa-sqlite", por usar Write-Ahead Log frente al rollback journal de SQLite.

    La doc avisa además de que comparar Postgres con SQLite es difícil y de que sus benchmarks son un punto de partida, no una sentencia.

    Traducción: sigue siendo órdenes de magnitud más rápido que la red, pero mide tus escrituras con persistencia activada antes de prometer nada.

    Sigue en 0.x

    @electric-sql/pglite está en la 0.5.8 y @electric-sql/pglite-sync en la 0.6.9. Pre-1.0 significa que la API puede moverse entre versiones menores.

    No es razón para descartarlo. Es razón para fijar la versión, leer los changelogs antes de actualizar y no esparcir PGlite por medio proyecto sin una capa propia delante.

    Cuándo NO usar local-first (y qué hacer en su lugar)

    La respuesta honesta es que a la mayoría de las apps no les toca.

    Tu situación Qué hacer
    App de IA de uso diario, con historial largo y propio de cada usuario Local-first con PGlite. Es tu caso.
    Datos sensibles que no deberían tocar tu servidor (salud, legal, finanzas) Local-first, y aquí es requisito, no optimización.
    Necesitas funcionar offline de verdad Local-first. No hay alternativa real.
    Datos compartidos que muchos usuarios editan a la vez Servidor. El coste de resolver conflictos se come la ganancia.
    Landing, e-commerce o cualquier app de sesión corta Servidor. 3 MB de arranque para dos consultas no sale.
    Necesitas consultar millones de filas que no caben en el cliente Servidor, con RAG clásico. Los shapes tienen un límite práctico.
    Equipo sin experiencia en sincronización de datos Servidor, hasta que el dolor justifique la curva.
    Tests de integración y CI que hoy levantan Docker con Postgres PGlite en Node o Bun. Sin migraciones ni sync, pero sigue siendo de una sola conexión.

    Esa última fila merece una nota. Aunque tu app no sea local-first, PGlite te sirve hoy en el pipeline: es un Postgres real, arranca en milisegundos y no necesita contenedor. Cambiar docker compose up por una instancia en memoria en tus tests es la puerta de entrada barata a esta tecnología. Con dos límites: al ser de una sola conexión ahí no vas a reproducir deadlocks, bloqueos entre sesiones ni el comportamiento de tu pool; y PGlite trae un catálogo concreto de extensiones, así que comprueba que las de tu esquema estén en la lista antes de tirar el Docker.

    Cómo decidir esto hoy, en diez minutos

    Responde a una sola pregunta: ¿los datos que tu IA necesita para responder son de un único usuario y caben en su dispositivo?

    Si es que sí, local-first con PGlite te saca el round-trip de red de la ruta crítica, te baja los tokens por turno y te da una historia de privacidad que tus competidores no pueden contar. Empieza por el worker, el esquema versionado, el camino de escritura y una estrategia de conflictos escrita antes de crear la primera tabla.

    Si es que no, quédate en el servidor y duerme tranquilo.

    Y si quieres ver este tipo de decisiones discutidas con proyectos reales delante, es lo que hacemos cada semana en Dominicode Labs.

    Preguntas frecuentes sobre local-first con PGlite

    ¿PGlite sustituye a mi Postgres del servidor?

    No. PGlite es un Postgres embebido de una sola conexión, pensado para vivir junto a la aplicación y no para servir a muchos clientes concurrentes. En una arquitectura local-first, PGlite es la fuente de verdad local del dispositivo y tu Postgres del servidor sigue siendo la del negocio: entre ambos hay sincronización —de servidor a cliente te la dan hecha, de cliente a servidor la montas tú—, no sustitución.

    ¿Puedo hacer RAG entero en el navegador con PGlite?

    Sí, siempre que el corpus sea del usuario y quepa en su dispositivo. PGlite soporta pgvector a través del paquete @electric-sql/pglite-pgvector, así que puedes guardar embeddings y hacer búsqueda por similitud en local sin que los documentos salgan del navegador. Lo que no puedes hacer en el cliente es RAG sobre un corpus corporativo de millones de documentos: eso sigue siendo trabajo de servidor.

    ¿Cuánto pesa PGlite y cómo afecta al arranque de la app?

    PGlite pesa menos de 3 MB gzipped. Se carga una vez y luego queda cacheado, pero es un coste real en el primer render, así que conviene cargarlo diferido después del primer pintado. En una app de uso diario se amortiza sin problema; en una página de sesión corta no compensa.

    ¿Qué pasa si el usuario abre la app en dos pestañas?

    PGlite admite una sola conexión, así que dos pestañas no pueden abrir dos instancias sobre la misma base de datos. La solución oficial es el multi-tab worker: una única instancia dentro de un Web Worker y una elección de líder que hace de proxy para todas las pestañas. Cuando la pestaña líder se cierra, se elige otra automáticamente y se levanta una instancia nueva.

    ¿Está listo para producción si sigue en 0.x?

    Depende de tu tolerancia a que la API cambie. @electric-sql/pglite está en la 0.5.8 y el plugin de sync en la 0.6.9, y pre-1.0 significa que puede haber cambios de API entre versiones menores. Hay proyectos en producción con PGlite, pero si entras, fija la versión exacta, lee los changelogs antes de cada actualización y aísla PGlite detrás de una capa propia para que un cambio de API no te toque cincuenta ficheros. Y si vas a hacer RAG en el navegador, mira el eslabón más verde de la cadena: el paquete de pgvector, @electric-sql/pglite-pgvector, va por la 0.0.9.

    ¿En qué se diferencia PGlite de IndexedDB o de SQLite en WASM?

    IndexedDB es un almacén clave-valor sin lenguaje de consultas: cualquier filtro o join lo escribes tú en JavaScript. SQLite compilado a WASM sí te da SQL y en memoria pura es más rápido que PGlite, pero es SQLite: otro dialecto y otras extensiones que las de tu servidor. PGlite es Postgres compilado a WASM sin máquina virtual Linux, así que ejecutas el mismo dialecto y un catálogo de extensiones que se solapa con el de tu servidor —pgvector incluida—, aunque no estén todas las de Postgres. En local-first, esa paridad es lo que evita mantener dos modelos de datos distintos.


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

  • Arquitectura de código generado con IA: la Casa Winchester

    Arquitectura de código generado con IA: la Casa Winchester

    Hace tres semanas abrí un repo que llevaba cuatro meses construyendo casi entero con agentes. Buscaba una función para formatear fechas.

    Encontré tres.

    formatDate en src/utils/date.ts. toDisplayDate en src/lib/format.ts. Y humanDate en src/shared/helpers/dates.ts. Las tres hacían lo mismo. Las tres estaban bien escritas. Las tres tenían tests. Ninguna estaba rota.

    Ese es el problema de la arquitectura de código generado con IA: no se rompe. Se desparrama. La arquitectura de código generado con IA es la forma que toma un repositorio cuando la mayor parte del código la escribe un agente y no una persona: cada pieza es correcta por separado, pero nadie sostiene el conjunto en la cabeza. El CI sigue verde mientras el repositorio se convierte en otra cosa.

    Se ha hablado mucho del "Deep Blue" — el término que se acuñó en el podcast Oxide and Friends, con crédito principal a Adam Leventhal y con Simon Willison en ese mismo episodio, que después lo difundió en su blog: esa mezcla de desánimo y vértigo existencial que sienten muchos developers ante los LLM. Este post no va de eso.

    Va de lo que le está pasando a tu repositorio ahora mismo, mientras tú lo miras.

    Qué es la Casa Winchester y por qué se parece a tu repo

    La Casa Winchester, en software, es el modelo que describe un repositorio construido a base de decisiones correctas tomadas de una en una, sin que nadie sostenga el plano general: cada pieza está bien hecha y el conjunto no tiene sentido. El nombre viene de una mansión real, y encaja con lo que produce hoy un agente de codificación.

    Sarah Winchester construyó durante décadas una mansión en San José, California. Sin arquitecto director y sin plano general. Cada obra se hacía bien: buena carpintería, buenos materiales, habitaciones perfectamente terminadas.

    El resultado tiene escaleras que acaban en el techo y puertas que abren al vacío.

    Ninguna decisión individual fue estúpida. Falló el conjunto. Nadie tenía en la cabeza la casa entera, así que nunca llegó a ser una casa: fue la suma de muchas obras correctas.

    Abre tu repo generado con agentes y busca ese patrón. No busques bugs. Busca escaleras que no llevan a ningún sitio.

    El tercer modelo: ni catedral ni bazar

    En 1997 Eric S. Raymond presentó La catedral y el bazar, el ensayo que definió los dos modelos con los que llevamos treinta años pensando el software. La catedral: planificada, cerrada, con arquitectos que controlan la forma. El bazar: abierto, caótico en la superficie, ordenado por muchos ojos mirando.

    Drew Breunig propuso en marzo de 2026 un tercero: la Casa Winchester. Su tesis es incómoda y precisa: "AI is making code cheap and kicking off a new era filled with idiosyncratic, sprawling, cobbled-together software".

    La clave no es que la IA escriba mal. Es esta otra frase suya: "Feedback hasn't gotten cheaper; the 'eyeballs' that guided the software developed by the bazaar haven't caught up to AI". El código bajó de precio. Todo lo demás —incluidos los ojos que Raymond puso en el centro del bazar— cuesta exactamente lo mismo que antes.

    Y remata: "There is only one source of feedback that moves at the speed of AI-generated code: yourself".

    Ahí está el problema entero. La revisión de un compañero, el diseño de una API, la discusión sobre si esto merece ser un módulo nuevo: todo eso sigue a velocidad humana. Lo único que se aceleró fue la producción.

    La entropía arquitectónica es la degradación progresiva de la forma de un repositorio —duplicación semántica, abstracciones sin uso, patrones incoherentes— sin que aparezca un solo fallo funcional. No es un problema de calidad de código: es un problema de caudal de feedback. Tu repo se desparrama porque el código llega más rápido de lo que nadie puede juzgarlo.

    Una catedral no se defiende sola: necesita a alguien mirando. El bazar tampoco, porque funcionaba gracias a que se leía despacio. Cuando el que escribe se acelera un orden de magnitud y el que lee sigue exactamente igual, no te queda ni catedral ni bazar. Te queda una casa con escaleras al techo.

    Las 4 señales de que tu arquitectura de código generado con IA ya es una Casa Winchester

    Esto no se detecta leyendo. Se detecta midiendo. Cuatro señales, cada una con su forma de verla hoy mismo.

    # Señal Cómo la mides
    1 La misma utilidad con tres nombres distintos Censo de exports con rg + jscpd
    2 Abstracciones con un solo consumidor Grafo de madge + in-degree
    3 Código muerto que nadie borra knip --reporter compact
    4 Patrones incoherentes entre sesiones Conteo de librerías rivales cruzado con git log

    1. La misma utilidad con tres nombres distintos

    Es la señal madre. El agente no encontró tu helper porque no estaba en su contexto, así que escribió otro. Correcto, con tests, y duplicado.

    # Censo de funciones exportadas: los duplicados semánticos saltan a la vista
    rg -o --no-filename 'export (?:async )?(?:function|const) (\w+)' -r '$1' src \
      | sort | uniq -c | sort -rn | head -30
    
    # Duplicación literal de bloques
    npx jscpd src --min-lines 5 --min-tokens 60 --reporters console
    

    El censo de nombres rinde más de lo que parece. Cuando ves formatDate, toDisplayDate y humanDate seguidos en la misma lista, el diagnóstico es inmediato.

    2. Abstracciones con un solo consumidor

    El agente te construye un UserRepository, un NotificationService y un PaymentGateway porque son buenas prácticas. Luego resulta que cada uno se usa exactamente desde un sitio.

    Una abstracción con un consumidor no es arquitectura. Es una capa de indirección que te cobra peaje cada vez que lees el código.

    npx madge --extensions ts,tsx --json src > deps.json
    

    Si tu repo usa path aliases (@/…), añade --ts-config tsconfig.json o madge no resolverá esos imports y el grafo saldrá incompleto — con lo que el in-degree te mentirá.

    Y un script de veinte líneas que cuenta cuántos módulos importan a cada módulo:

    // scripts/in-degree.mjs
    import { readFileSync } from 'node:fs'
    
    const graph = JSON.parse(readFileSync('deps.json', 'utf8'))
    const inDegree = new Map(Object.keys(graph).map((file) => [file, 0]))
    
    // Los tests no cuentan como consumidor: si el único importador de un módulo
    // es su test, ese módulo tiene cero consumidores de producción, no uno.
    for (const [file, deps] of Object.entries(graph)) {
      if (file.includes('.test.') || file.includes('.spec.')) continue
      for (const dep of deps) {
        inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1)
      }
    }
    
    const suspects = [...inDegree]
      .filter(([file, count]) => count === 1 && !file.includes('.test.'))
      .map(([file]) => file)
      .sort()
    
    console.log(`Módulos con un único consumidor: ${suspects.length}`)
    console.log(suspects.join('\n'))
    

    Ejecútalo con node scripts/in-degree.mjs. Esa lista es tu deuda de indirección con nombres y apellidos.

    3. Código muerto que nadie borra

    Un agente borra cuando se lo pides. Nunca por iniciativa propia, porque borrar es arriesgado y su incentivo es que la tarea pase. Así que el código viejo se queda ahí, acumulándose y ensuciando el contexto de la siguiente sesión.

    npx knip --reporter compact
    

    knip te da ficheros, exports y dependencias que nadie usa. Apunta el número de hoy en algún sitio del repo. Si dentro de un mes ha subido, ya tienes tu métrica de entropía.

    4. Patrones incoherentes entre sesiones

    Esta es la más silenciosa. El módulo que escribiste en junio usa fetch a pelo. El de julio usa TanStack Query. El de agosto se trajo axios porque el agente decidió que era lo estándar.

    for p in "axios" "fetch(" "@tanstack/react-query" "HttpClient"; do
      printf "%-24s %s\n" "$p" "$(rg -l --fixed-strings "$p" src | wc -l)"
    done
    

    Si más de una fila devuelve un número mayor que cero, tienes dos maneras de hacer lo mismo conviviendo en el repo. Cruza el resultado con git log --diff-filter=A --format='%ad' --date=short -- <fichero> y verás que cada patrón corresponde a una tanda distinta de trabajo.

    Escaleras que dan al techo: por qué se degrada la arquitectura de código generado con IA

    Tres causas, y ninguna es "la IA escribe mal".

    El agente empieza cada sesión con amnesia parcial. No lee tu repo entero: lee lo que le cabe en la ventana y lo que sabe buscar — el mismo mecanismo que provoca el context drift. Si tu helper de fechas no aparece en esa muestra, para el agente no existe. Y lo que no existe, se escribe.

    Escribir se volvió más barato que entender. Esto siempre fue verdad, pero antes tecleabas tú, y el coste de escribir 200 líneas te empujaba a reutilizar. Esa fricción desapareció. Hoy reutilizar exige buscar, leer y decidir; crear exige una frase. El camino de menor resistencia lleva al código nuevo.

    El CI que ya tienes no ve nada de esto. Ningún test se pone rojo porque tengas tres formas de formatear fechas. Ningún linter falla porque una capa tenga un solo consumidor. Tus tests miden comportamiento; la entropía es un problema de forma. Es un punto ciego distinto del que conté en los 5 fallos del código generado por IA que un code review no puede ver: allí el diff esconde el fallo, aquí no hay fallo que esconder. Por eso el repo se degrada durante meses con el pipeline en verde.

    Aquí mucha gente responde con más proceso humano: más revisión, más reuniones de arquitectura. No funciona, y Breunig ya te dijo por qué: tú eres el único feedback que va a la velocidad del código, y tú no escalas.

    La respuesta tiene que ir a la misma velocidad que el problema. Es decir: automática.

    Guías y sensores: el plano que le falta al agente

    Birgitta Böckeler publicó el 2 de abril de 2026 en martinfowler.com un artículo sobre harness engineering con la formulación más clara que he leído del asunto. El harness engineering es la disciplina de diseñar todo lo que rodea al modelo —contexto, herramientas, verificaciones y bucles de corrección— para que el agente necesite menos supervisión humana. Su punto de partida: Agent = Model + Harness. El modelo no lo controlas. El arnés agéntico sí, y es tuyo entero.

    El arnés tiene dos mitades.

    Guías (feedforward). Fijan expectativas antes de que el agente actúe. Suben la probabilidad de que acierte a la primera. Documentación de arquitectura, convenciones, instrucciones de arranque.

    Sensores (feedback). Observan la salida después y permiten autocorrección. Böckeler insiste en un detalle que casi todo el mundo se salta: los sensores deben estar "optimised for LLM consumption" — mensajes que le digan al agente qué hacer, no solo qué falló.

    Y cada mitad puede ser computacional (determinista y rápida: tipos, lint, tests, build; milisegundos y resultado fiable) o inferencial (semántica: revisión por LLM, LLM-as-judge; más lenta, más cara y no determinista).

    Guías (antes de actuar) Sensores (después de actuar)
    Computacional (determinista, ms) Tipos, esquemas, plantillas, AGENTS.md con el mapa del repo tsc, ESLint, tests, knip, jscpd
    Inferencial (semántico, lento y caro) How-tos y ejemplos escritos para consumo del LLM Revisión por LLM en el PR, LLM-as-judge

    La conclusión que saco de su artículo es la parte que importa: ninguna mitad vale sola. Solo sensores y tienes un agente que repite siempre los mismos errores. Solo guías y tienes un agente que memoriza reglas sin enterarse nunca de si funcionaron.

    La guía: tu fichero de instrucciones no es un style guide

    El error más común en AGENTS.md o CLAUDE.md es llenarlo de preferencias de formato. Eso ya lo hace Prettier.

    La guía debe contener lo que el agente no puede deducir mirando un fichero suelto: dónde vive cada cosa, quién puede importar a quién y qué existe ya.

    MAPA DEL REPO — no crees carpetas de primer nivel sin preguntar
    
    - `src/domain/`  — tipos y reglas de negocio. No importa NADA de `src/infra/`.
    - `src/infra/`   — HTTP, DB, colas. Implementa los puertos de `src/domain/`.
    - `src/app/`     — casos de uso. Único sitio que orquesta domain + infra.
    - `src/shared/`  — fuente ÚNICA de fechas, dinero y formateo de strings.
    
    ANTES DE ESCRIBIR CUALQUIER UTILIDAD NUEVA
    
    Ejecuta esto y lee la salida. Si algo cubre el 80% del caso, extiéndelo:
    
        rg -n "export (async )?function" src/shared
    
    REGLAS DURAS
    
    - Una sola librería de fetching: `@tanstack/react-query`. Nada de `axios`.
    - No crees una abstracción con menos de dos consumidores reales.
    - Si un código sobra, bórralo. No lo comentes ni lo marques `@deprecated`.
    
    DEFINICIÓN DE "HE TERMINADO"
    
        pnpm agent:check
    

    Ese último bloque es la bisagra entre la guía y los sensores. Si tu definición de "terminado" es "el agente dijo que estaba", no tienes arnés: tienes fe.

    Si quieres un AGENTS.md ya escrito para copiar y adaptar, lo tienes entero en Revisión por Contrato, un ebook gratuito de 30 páginas donde desarrollo el contrato, el carril y el veredicto que le pones a un agente antes de dejarle tocar el repo.

    Fijar la forma antes de que exista el código es el mismo músculo que entrenas con Spec-Driven Development. Si quieres el método completo, lo desarrollo entero en el libro Spec Driven Development.

    Los sensores computacionales: que el agente se corrija solo

    Un único comando que el agente pueda ejecutar sin pedirte permiso:

    {
      "scripts": {
        "typecheck": "tsc --noEmit",
        "lint": "eslint . --max-warnings 0",
        "test": "vitest run",
        "dead": "knip --reporter compact",
        "dupes": "jscpd src --min-tokens 60 --threshold 1 --reporters console,threshold",
        "agent:check": "pnpm typecheck && pnpm lint && pnpm test && pnpm dead && pnpm dupes"
      }
    }
    

    knip y jscpd son los dos que faltan en casi todos los repos, y son justo los que detectan entropía en vez de bugs. jscpd con --threshold 1 sale con código 1 si la duplicación pasa del 1%, pero solo si añades el reporter threshold: el flag por sí solo no cambia el código de salida. Con los dos juntos, "hay algo duplicado" pasa de ser un texto en consola a una señal que el agente lee y sobre la que puede actuar.

    El sensor que más me ha servido es otro: convertir la dirección de dependencias en una regla de lint cuyo mensaje explique el arreglo.

    // eslint.config.js
    export default [
      {
        files: ['src/domain/**/*.ts'],
        rules: {
          'no-restricted-imports': ['error', {
            patterns: [{
              group: ['**/infra/**', 'axios', 'node:fs'],
              message:
                'domain/ no puede importar de infra/. Define un puerto (interfaz) en ' +
                'src/domain/ports/, impleméntalo en src/infra/ e inyéctalo desde el ' +
                'caso de uso en src/app/. No muevas el fichero: mueve la dependencia.',
            }],
          }],
        },
      },
    ]
    

    Fíjate en el mensaje. No dice "import restringido". Dice qué hacer, en qué orden y con qué carpetas. El agente lo lee, lo aplica y no te interrumpe. Eso es un sensor optimizado para consumo de LLM.

    Dos detalles que te ahorran un rato: export default en eslint.config.js exige "type": "module" en el package.json —o renombrar el fichero a eslint.config.mjs—, y si quieres bloquear también los import type de TypeScript necesitas @typescript-eslint/no-restricted-imports en vez de la regla core. La regla en sí no es más que Clean Architecture convertida en algo que el agente puede ejecutar.

    La misma lógica aplicada a la ejecución del agente la desarrollo en el post sobre guardrails para agentes con acceso a terminal y base de datos, y llevada a testear al propio agente en el de test harness para agentes de IA.

    Los sensores inferenciales: para lo que ningún linter ve

    Hay preguntas que ninguna regla determinista responde. ¿Esta función duplica algo que ya existe con otro nombre? ¿Esta abstracción tiene razón de ser? ¿Este módulo sigue el patrón del resto del repo?

    Eso es trabajo de un revisor LLM en el PR, con un prompt que pregunte por coherencia y no por corrección. La corrección ya la cubren los tipos y los tests. Lo que te falta es alguien que mire la casa entera. Cómo montarlo lo cuento en el post de agentic code review.

    Es más lento y no determinista, sí. Por eso va en el PR y no en cada guardado.

    Qué revisar en tu repo esta semana

    Cinco cosas, por orden. Ninguna te lleva más de una tarde.

    1. Saca tu línea base. Ejecuta npx knip y npx jscpd src hoy. Apunta los dos números en un fichero del repo con la fecha. Sin línea base no sabes si mejoras o empeoras.
    2. Abre tu AGENTS.md o CLAUDE.md. Si lo que hay dentro es un style guide, reescríbelo como mapa: dónde vive cada cosa y quién importa a quién.
    3. Añade agent:check al package.json y ponlo en la guía como definición literal de "he terminado".
    4. Convierte una regla de arquitectura en lint, con mensaje accionable. Una sola. La dirección de dependencias es la que más rinde.
    5. Aplica la regla de los dos consumidores. Coge la salida del script de in-degree, elige una abstracción que solo se use una vez y bórrala metiendo el código donde se usa. Vas a respirar mejor.

    La conclusión después de cuatro meses generando código con agentes es esta: el agente no tiene criterio arquitectónico, tiene contexto. Si tu criterio no está escrito en la guía y no lo verifica un sensor, para el agente no existe. Y lo que no existe se reinventa cada sesión con un nombre distinto.

    Sarah Winchester tenía dinero, buenos carpinteros y décadas por delante. Le faltó el plano. Tú tienes agentes que escriben más rápido de lo que nadie puede leer. El plano ya no es opcional.

    Si quieres ver el flujo completo funcionando — guías, sensores y agentes dentro de un arnés que aguanta — lo monto paso a paso en el curso Construye con IA: de la idea al producto con Claude Code. Y si prefieres trabajarlo sobre proyectos reales, eso pasa en Dominicode Labs.

    Preguntas frecuentes

    ¿Qué es la entropía arquitectónica en código generado con IA?

    Es la degradación de la forma de un repositorio sin que aparezca ningún fallo funcional: tres funciones que hacen lo mismo con nombres distintos, abstracciones con un único consumidor, código muerto que nadie borra y patrones que cambian según la sesión en que se escribió cada módulo. Se distingue de un bug en que ningún test la detecta: los tests miden comportamiento y la entropía es un problema de estructura. Se mide con herramientas de duplicación (jscpd), de código muerto (knip) y de grafo de dependencias (madge).

    ¿Esto no es simplemente deuda técnica de toda la vida?

    Misma familia, otra dinámica. La deuda técnica clásica la generas tú y la sientes al escribirla: sabes que estás tomando un atajo. Esta la genera un agente que hace las cosas bien en cada tarea individual, así que nunca hay atajo consciente ni sensación de deuda. Se acumula sin fricción y sin señal. Por eso hay que medirla, no intuirla.

    Si trabajo solo, ¿esto me afecta igual?

    Más. El modelo de la Casa Winchester describe precisamente proyectos personales donde el bucle de feedback se colapsa dentro de una sola cabeza. Sin nadie que revise, tu única defensa son los sensores automáticos. Un equipo grande al menos tiene pull requests con humanos delante; tú tienes exactamente lo que hayas automatizado.

    ¿Cuánto debe ocupar el fichero de instrucciones del agente?

    Corto y denso. Si pasa de una pantalla y media, el agente empieza a ignorar partes. Prioriza el mapa del repo, tres o cuatro reglas duras y el comando de verificación. Todo lo que se pueda comprobar con un linter, sácalo del fichero y ponlo como sensor: ahí sí se cumple siempre.

    ¿Los sensores inferenciales sustituyen al code review humano?

    No, lo reordenan. El revisor LLM absorbe el volumen y filtra lo obvio: duplicación, incoherencia de patrones, abstracciones sin uso. Tú te quedas con lo que exige criterio de producto y de negocio. Si intentas leer cada línea que produce un agente, vuelves al cuello de botella del que veníamos.

    Mi repo ya es una Casa Winchester. ¿Reescribo?

    No. Las reescrituras completas con agentes fallan por la misma razón que falló el repo original: mucho código y poco feedback. Congela primero — mete los sensores y la guía para que la entropía deje de crecer. Después ataca una zona por semana, empezando por las utilidades duplicadas: son las más baratas de unificar y las que más contexto sucio limpian para las sesiones siguientes.


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

  • Shopify compra Tailwind: no falló el framework, falló el embudo

    Shopify compra Tailwind: no falló el framework, falló el embudo

    El 18 de noviembre de 2025, un contribuidor abrió un pull request en el repositorio de la documentación de Tailwind. La propuesta era razonable: un endpoint llms.txt que sirviera toda la doc en un único archivo de texto, optimizado para que lo consumieran los modelos de lenguaje.

    El PR se quedó parado siete semanas. A principios de enero alguien preguntó en el hilo por qué no avanzaba. Adam Wathan lo cerró el 6 de enero de 2026, y al día siguiente contestó con algo que no era la respuesta a un PR:

    "75% of the people on our engineering team lost their jobs here yesterday because of the brutal impact AI has had on our business."

    "El 75 % de la gente de nuestro equipo de ingeniería perdió su trabajo ayer por el impacto brutal que la IA ha tenido en nuestro negocio."

    Eran cuatro ingenieros. Quedó uno.

    Ocho meses después llegó el desenlace: el 9 de septiembre de 2026, Shopify compra Tailwind Labs. Ni Shopify ni Tailwind Labs han revelado el importe.

    La lectura fácil es "gran salida para un proyecto querido". La útil es otra: aquí no falló el framework. Falló el embudo por el que ese framework se pagaba.


    Qué dice el anuncio de la compra de Tailwind por Shopify

    El anuncio, firmado por Adam Wathan el 9 de septiembre de 2026, se apoya en una promesa. Wathan la escribe sin ambigüedad: "Nothing changes with Tailwind CSS or any of our other open-source projects". Y refuerza: "Everything will always be MIT-licensed". El equipo sigue liderando y manteniendo los proyectos open source, ahora con el respaldo de Shopify.

    Con el negocio es más seco: "On the commercial side, we'll no longer be trying to grow the business around Tailwind". Los clientes actuales de Tailwind Plus y de ui.sh conservan su acceso. Lo que sí cierra son las altas nuevas: "we're closing sign ups for new customers to focus on Tailwind CSS at Shopify".

    La razón declarada es de alineación estratégica: Wathan siempre quiso que el framework se desarrollara al servicio de un producto real, y Shopify fue adoptante temprano —lo usa internamente y con sus merchants.

    Todo eso es cierto. Y no menciona despidos ni caída de ingresos, porque un anuncio de adquisición no es donde se cuenta eso.

    Pero sin esa parte, la historia no se entiende.


    El dato que no cuadra: 69,9 millones de descargas y los ingresos cayendo

    La contradicción es esta: en septiembre de 2026 Tailwind CSS se descarga más que nunca mientras los ingresos de la empresa que lo mantiene caen casi un 80 %.

    Tailwind es un framework de CSS basado en clases de utilidad: compones el estilo en el propio marcado en vez de escribir hojas aparte. Lo expliqué a fondo en qué es Tailwind CSS, y desde entonces se ha comido buena parte del front-end moderno.

    Eso no es entusiasmo. Es medible.

    La semana del 3 al 9 de septiembre de 2026, el paquete tailwindcss registró 69,9 millones de descargas semanales en npm. Para situar la escala: React tuvo 95,7 millones esa misma semana.

    Ponlo al lado del resto de señales.

    Señal Dirección
    Descargas semanales de tailwindcss 69,9 M (3-9 de septiembre de 2026)
    Tráfico a la documentación −40 % desde principios de 2023
    Ingresos casi −80 %
    Equipo de ingeniería −75 % (enero de 2026)

    Descargas: API de npm — 69.920.618 en la semana del 3 al 9 de septiembre de 2026. Tráfico e ingresos: cifras que dio el propio Wathan en el hilo del PR #2388 el 6 y el 7 de enero de 2026. El anuncio de la adquisición no menciona ninguna de las dos.

    Lee la tabla otra vez. La adopción sube. El tráfico y el dinero se desploman.

    Esa contradicción no es una curiosidad. Es el post entero.


    El embudo que rompió la IA: la documentación era el canal de adquisición

    El embudo que se rompió es el de la documentación: durante más de una década, una empresa de herramientas para developers regalaba el open source y monetizaba las visitas que su doc traía desde Google. Funcionaba tan bien que dejamos de mirarlo.

    Regalas el open source. La documentación posiciona en Google porque es contenido técnico específico y con una intención de búsqueda altísima. El developer aterriza a resolver un problema concreto y una fracción descubre por el camino que además vendes algo.

    La documentación no era un centro de coste. Era el canal de adquisición, y durante años el más barato de todos: gratis, evergreen y perfectamente cualificado.

    Ese embudo se rompió. Despacio en el tráfico, de golpe en el dinero.

    Cada vez menos gente aterriza en tailwindcss.com para buscar cómo se hace un grid. Se lo pregunta a Claude, o lo escribe Cursor mientras mira otra cosa. La respuesta llega, es correcta, y nadie visitó la web. La doc se consume más que nunca, pero a través de un intermediario que no monetiza para ti.

    Tailwind no perdió usuarios. Perdió visitas.

    Y su negocio cobraba por visitas.


    La IA no mató a Tailwind sola: shadcn y el encaje del producto de pago

    Cuidado con la narrativa cómoda: "la IA mató a Tailwind" es demasiado limpio para ser verdad.

    En el hilo de Hacker News que siguió a los despidos —1.457 puntos y 840 comentarios— varios clientes de pago decían lo mismo: los componentes de Tailwind Plus les resultaban complicados y demasiado opinados, y shadcn —gratis— les encajaba mejor. Eso no lo provocó ningún modelo de lenguaje. Es competencia normal, de la que ya existía antes de ChatGPT.

    El producto de pago tenía un problema de encaje. Lo que hizo la IA fue quitarle el colchón.

    Con un canal sano, un producto que pierde encaje te da años para corregir el rumbo. Si el canal lleva tres años cayendo hasta perder un 40 %, no te quedan años: te quedan trimestres. Y con los ingresos cayendo casi un 80 %, la decisión ya no es qué construir, sino a quién llamar.

    Y que quede claro: la adquisición no es el fracaso de esta historia, es probablemente el mejor final posible. Un paquete con 69,9 millones de descargas semanales ahora lo paga una empresa con caja, con licencia MIT y el equipo original al mando.

    El fracaso, si acaso, es de un modelo. No de Wathan.


    Por qué te importa aunque no vendas componentes

    Es probable que tengas un producto propio o quieras tenerlo. Un SaaS, un curso, una plantilla, una librería con versión Pro. Yo tengo varios.

    Hazte una pregunta y respóndela con datos, no con intuición:

    ¿Cómo se entera un desconocido de que tu producto existe?

    Si la respuesta es "busca en Google, aterriza en mi web y ahí descubre que vendo algo", tienes exactamente el embudo de Tailwind. Solo que todavía no lo has medido.

    Ahí está el corolario incómodo: la IA no te va a quitar el trabajo de escribir código, pero puede quitarte el canal por el que vendías el código que escribes.

    Cuando escribí sobre micro-SaaS rentables como developer en solitario, la parte que más preguntas generó fue la de distribución. Con razón: el producto es lo que ya sabemos hacer; el canal es lo que decide si existe o no.

    Tres números que puedes mirar esta semana

    1. Porcentaje de altas que llegan por búsqueda orgánica. Si más de la mitad de tus clientes nuevos entran por ahí, dependes de un canal que se mueve bajo tus pies.
    2. Impresiones contra clics en los últimos 24 meses. Si las impresiones aguantan y los clics bajan, no te has hundido en el ranking: la respuesta se sirve sin ti. Es la señal temprana, y llega mucho antes que la caída de ingresos.
    3. Cuántos compradores tenían relación previa contigo. Email, comunidad, canal, lo que sea. Ese porcentaje es tu independencia real del algoritmo de turno.

    Si el tercero es bajo, no tienes un negocio. Tienes un alquiler de tráfico.


    Qué modelos de negocio aguantan cuando la IA se queda el clic

    Aguantan los modelos de negocio que no dependen del clic, y hay menos de los que parece.

    Distribución directa. Una lista de email, un canal propio, un feed que alguien eligió seguir. Entre tú y una bandeja de entrada hay mucho menos intermediario que entre tú y un resultado de búsqueda. Mi canal de YouTube hace justo eso: nadie llega a un vídeo mío preguntándole a Claude cómo se centra un div.

    Comunidad. Gente que vuelve porque hay alguien al otro lado respondiendo. Un LLM contesta la pregunta que sabes formular; no te dice cuál deberías estar haciéndote. En Dominicode Labs esa es la propuesta entera: gente que vuelve cada semana porque hay alguien al otro lado. No hay algoritmo intermedio que pueda cortarlo.

    Producto que la IA no puede regenerar. Un componente de UI se regenera en veinte segundos. Un backend con estado, datos propietarios, SLA y alguien de guardia, no. Cuanto más cerca esté tu producto de "esto lo escribe un modelo en un prompt", más frágil es tu precio.

    Cobrar por el producto, no por el tráfico que genera. Open source más hosting, soporte o servicio gestionado. Es, por cierto, lo contrario de lo que tenía Tailwind: el framework nunca cobró por sí mismo, cobraba la doc que lo explicaba. Ahora lo financia el producto real de otro.

    Ninguna de las cuatro es rápida. Todas se construyen antes de necesitarlas.


    Si tu único canal es el SEO: qué cambiar ahora

    No lo abandones. Sería la reacción exagerada.

    Cámbiale el trabajo. Tu contenido ya no compite por el clic: compite por ser la fuente que el modelo cita. Escribe para que te citen, estructura para que te parseen, publica tu llms.txt —sí, el mismo que Tailwind no pudo permitirse— y deja de medir el éxito en páginas vistas. Lo desglosé paso a paso en SEO vs GEO para developers: cómo conseguir que las IAs citen tus tutoriales.

    Y asume la parte dura: la conversión ya no ocurre en tu web, ocurre después. Cada visita tiene que dejar algo —un email, una suscripción—, porque puede ser la única vez que esa persona pise tu dominio.

    Hay una ironía aquí: aquel PR proponía exactamente esto, un llms.txt con toda la doc. Wathan dijo que le veía el valor y que quería añadirlo, pero que no podía priorizar algo que le quitaba el único canal por el que llegaban clientes. Tenía razón en el diagnóstico. El problema no era el llms.txt: era que el negocio colgaba entero de las visitas. Eso es lo que hay que mover, y hay que moverlo antes de que te lo muevan.

    Montar ese otro sitio —producto propio, distribución y todo lo que hay entre la idea y el primer cliente— es lo que trabajamos paso a paso en el curso Construye con IA: de la idea al producto con Claude Code.


    Lo único que me llevaría de la compra de Tailwind por Shopify

    Que un framework con 69,9 millones de descargas semanales dejara a su empresa con los ingresos casi un 80 % por debajo significa que el uso y el negocio son cosas distintas, y que llevábamos años confundiéndolas porque el SEO nos hacía el favor de unirlas.

    Abre tu analítica hoy: de tus últimos veinte clientes, cuántos te conocían de antes.

    Ese número es tu modelo de negocio real. Lo demás es tráfico prestado.


    Preguntas frecuentes

    ¿Cuánto pagó Shopify por Tailwind Labs?

    No se ha hecho público. Ni el anuncio publicado por Adam Wathan el 9 de septiembre de 2026 ni Shopify han revelado el importe ni la estructura de la operación.

    ¿Por qué ha comprado Shopify a Tailwind Labs?

    Por alineación estratégica, según el anuncio: Shopify fue adoptante temprano de Tailwind CSS y lo usa internamente y con sus merchants, y Wathan siempre quiso desarrollar el framework al servicio de un producto real. El contexto que el anuncio no menciona es económico: en enero de 2026 Tailwind Labs despidió al 75 % de su equipo de ingeniería y el tráfico a su documentación estaba un 40 % por debajo del de principios de 2023.

    ¿Tailwind CSS sigue siendo gratis y open source tras la compra de Shopify?

    Sí. Wathan lo dice literalmente en el anuncio: "Nothing changes with Tailwind CSS or any of our other open-source projects" y "Everything will always be MIT-licensed". El mismo equipo sigue manteniéndolos, ahora con el respaldo de Shopify. Si usas Tailwind en producción, no tienes nada que tocar.

    ¿Qué pasa con Tailwind Plus y ui.sh si ya soy cliente?

    Los clientes actuales mantienen su acceso. Lo que cambia es que se cierran las altas nuevas: en palabras del anuncio, cierran los registros "to focus on Tailwind CSS at Shopify". El producto de pago deja de crecer para que el equipo se centre en el framework dentro de Shopify.

    ¿Por qué cayeron los ingresos de Tailwind si el framework se usa más que nunca?

    Porque cobraban en un sitio distinto de donde estaba el uso. El dinero llegaba por la documentación: posicionaba en Google, el developer aterrizaba a resolver una duda y una parte descubría que existía un producto de pago. Con los asistentes de IA la respuesta llega sin visitar la web. El tráfico a la doc cayó alrededor de un 40 % desde principios de 2023 y los ingresos casi un 80 %, mientras la adopción seguía subiendo.

    ¿Significa esto que el SEO ya no sirve para vender productos para developers?

    Sirve, pero ha cambiado de función. Antes el contenido técnico traía visitas y las visitas traían clientes. Ahora compite por ser la fuente que citan los modelos, y la conversión ocurre fuera de tu web. La conclusión no es dejar de escribir: es dejar de medir en páginas vistas y hacer que cada visita deje una relación directa.

    ¿Debería dejar de usar Tailwind CSS en mis proyectos?

    No hay razón técnica para hacerlo. Mantiene licencia MIT, lo lleva el mismo equipo y ahora hay detrás una empresa que puede financiarlo. El riesgo de continuidad es menor hoy que en enero.


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

  • React 19.3: la release para borrar código, no para escribirlo

    React 19.3: la release para borrar código, no para escribirlo

    Hace un mes revisé el repo de un cliente. Una app de e-commerce, React, tres años de vida, gente competente detrás.

    Busqué isMounted en el proyecto. Diecinueve resultados. Busqué wrapperRef. Once. Y en el package.json, una librería de animación de 40 KB que solo se usaba para hacer un fade entre dos pantallas.

    Ninguna de las tres es un error. Son workarounds: código que existe porque React no daba la primitiva y alguien lo resolvió con lo que había.

    React 19.3 salió estable en npm el 9 de septiembre de 2026 y es, sobre todo, una release para borrar ese tipo de código. El problema de un workaround es que se queda para siempre, y cada dev nuevo del equipo asume que así es como se hace.

    No trae un paradigma nuevo. Trae dos APIs que salen de experimental —View Transitions y Fragment Refs—, una nueva, browser(), y un ajuste en Server Components. Cada una sustituye un apaño concreto que llevas años arrastrando. Vamos una por una, con lo que se borra en cada caso.

    En resumen: React 19.3 (react@19.3.0 y react-dom@19.3.0, publicados en npm el 9 de septiembre de 2026) estabiliza View Transitions y Fragment Refs, estrena browser() en react-dom, permite renderizar un Context directamente desde un Server Component y mejora el soporte de Trusted Types. No documenta ningún breaking change.

    Cambio Estado en 19.3 Se importa de Qué código elimina
    <ViewTransition> Estable (era experimental) react Librería de animación para transiciones de página y enter/exit de listas
    addTransitionType() Nueva react Estado de dirección pasado por props hasta el componente animado
    Fragment con ref Estable (era experimental) react El <div> wrapper que solo existía para colgar un ref, y el cloneElement
    browser() Nueva react-dom El trío useState + useEffect + flag mounted
    Context en Server Components Nuevo — El componente 'use client' que solo renderizaba el Provider
    Trusted Types Nuevo react-dom Sanitizado manual de TrustedHTML / TrustedScript

    View Transitions en React 19.3: borra la librería de animación

    <ViewTransition> ya no es experimental. Se importa de react y envuelve el trozo del árbol que quieres animar.

    import { ViewTransition, useState, startTransition } from 'react';
    
    export default function Component() {
      const [showItem, setShowItem] = useState(false);
      return (
        <>
          <button onClick={() => { startTransition(() => { setShowItem(prev => !prev); }); }}>
            {showItem ? '➖' : '➕'}
          </button>
          {showItem && (
            <ViewTransition>
              <Video video={videos[0]} />
            </ViewTransition>
          )}
        </>
      );
    }
    

    No hay animate, no hay variants, no hay AnimatePresence. Envuelves y ya. Por debajo React se apoya en la View Transition API del navegador.

    Hay cuatro formas de activarlo, según lo que pase en el árbol:

    • enter — se añade un ViewTransition al árbol.
    • exit — se elimina.
    • update — cambian sus hijos, sea contenido o style.
    • share — un ViewTransition con nombre desaparece en un sitio y aparece en otro.

    Cada tipo tiene su prop del mismo nombre —enter, exit, update, share— y su event prop: onEnter, onExit, onUpdate, onShare. Y existe además default, que fija la animación de los tipos que no declares: es lo que te permite apagarlos todos de golpe con default="none".

    Y ahora la regla que te va a costar media hora si no la lees: un setState normal no dispara la animación. Solo se activa dentro de una Transition: startTransition, useTransition, useDeferredValue o una navegación de Suspense.

    No es un detalle de implementación, es la decisión de diseño central: React no anima por si acaso, anima cuando tú marcas el cambio como transición. Si tu <ViewTransition> no hace nada, mira el setState antes que el CSS.

    Segundo detalle: la direccionalidad. El caso clásico —un carrusel que debe animar distinto según vayas hacia delante o hacia atrás— se resuelve con addTransitionType, otra función nueva de react:

    import { addTransitionType, startTransition } from 'react';
    
    function nextSlide() {
      startTransition(() => {
        addTransitionType('next');
        setCurrentSlide(c => c + 1);
      });
    }
    

    Etiquetas la transición y luego la consumes en CSS con :active-view-transition-type(next), o mapeas tipo → animación directamente en el componente:

    <ViewTransition
      enter={{ 'next': 'from-right', 'previous': 'from-left' }}
      exit={{ 'next': 'to-left', 'previous': 'to-right' }}
    >
      <Page />
    </ViewTransition>
    

    Ese mapa { tipo: animación } sustituye al estado de dirección que antes mantenías, pasabas por props hasta el componente animado y traducías a variants. Ahora vive donde ocurre la navegación.

    Y cuando dentro hay un Suspense, la doc recomienda animar solo el cambio de fallback a contenido y no todo lo que se mueva por debajo:

    <ViewTransition update="auto" default="none">
      <Suspense fallback={<Fallback />}>
        <Component />
      </Suspense>
    </ViewTransition>
    

    Qué borras: las transiciones de página y los enter/exit de listas y modales. Ojo, no digo que desinstales tu librería de animación mañana: los gestos, los drags, los springs físicos y las animaciones interrumpibles siguen siendo territorio de Framer Motion o GSAP. Pero si la instalaste para hacer un fade entre rutas —que es la mayoría de los casos que veo en revisiones— ya no la necesitas.


    Fragment Refs en React 19.3: un ref sin el <div> wrapper

    Fragment Refs permiten pasar un ref a un <Fragment> y recibir un FragmentInstance que opera sobre los hijos de primer nivel sin añadir ningún nodo al DOM. Son estables desde React 19.3.

    Es el cambio más pequeño de la release y probablemente el que más veces al mes te va a ahorrar un mal rato.

    Fragment ahora acepta ref. Te devuelve un FragmentInstance que opera sobre los hijos de primer nivel, sin meter un nodo extra en el DOM.

    Los métodos disponibles son estos:

    Categoría Métodos
    Eventos addEventListener, removeEventListener, dispatchEvent
    Foco focus() (en profundidad, depth-first), focusLast(), blur()
    Observers observeUsing(observer), unobserveUsing(observer)
    Layout y DOM getClientRects(), getRootNode(), compareDocumentPosition(otherNode), scrollIntoView(options)

    Un componente InView que antes exigía envolver los hijos en un div —con el consiguiente estropicio si el padre era un grid o un flex— ahora se escribe así:

    import { Fragment, useRef, useLayoutEffect } from 'react';
    
    export default function InView({ onChange, children }) {
      const fragmentRef = useRef(null);
    
      useLayoutEffect(() => {
        const visibleElements = new Set();
        const observer = new IntersectionObserver((entries) => {
          entries.forEach(e => {
            if (e.isIntersecting) visibleElements.add(e.target);
            else visibleElements.delete(e.target);
          });
          onChange(visibleElements.size > 0);
        });
        const fragmentInstance = fragmentRef.current;
        fragmentInstance.observeUsing(observer);
        return () => { fragmentInstance.unobserveUsing(observer); };
      }, [onChange]);
    
      return <Fragment ref={fragmentRef}>{children}</Fragment>;
    }
    

    Fíjate en observeUsing: le pasas el observer y él se encarga de suscribir a cada hijo. No iteras children, no clonas elementos, no pides refs a los hijos.

    Para accesibilidad, mover el foco al primer elemento enfocable de un grupo se queda en dos líneas:

    import { Fragment, useRef, useEffect } from 'react';
    
    function Component() {
      const fragmentRef = useRef(null);
    
      useEffect(() => {
        const fragmentInstance = fragmentRef.current;
        fragmentInstance.focus();
      }, []);
    
      return (
        <Fragment ref={fragmentRef}>
          {posts.map(post => (
            <Heading key={post.id}>{post.title}</Heading>
          ))}
        </Fragment>
      );
    }
    

    Qué borras: los wrappers de layout que no pintan nada y el cloneElement con ref que usabas para llegar a los hijos. Es el mismo tipo de deuda que genera el props drilling en React: estructura que existe para transportar algo, no para representar nada.


    use(browser()) en React 19.3: borra el flag mounted

    browser() es una función nueva de react-dom que, consumida con use(browser()), suspende en el servidor y no suspende en el cliente: marca un componente como client-only sin useState ni useEffect.

    Es el cambio más discreto de la release y el que más código muerto elimina en una app con SSR.

    El patrón que todos hemos escrito mil veces: un componente necesita window, Intl, localStorage o cualquier cosa que solo existe en el navegador, así que montas el ritual de useState(false) + useEffect(() => setMounted(true), []) + if (!mounted) return null.

    React 19.3 mete browser() en react-dom. Se consume con use() y su comportamiento cabe en una línea: suspende en el servidor y no suspende en el cliente.

    import { Suspense, use } from 'react';
    import { browser } from 'react-dom';
    
    function TimeZone() {
      use(browser());
      const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
      return <p>{timeZone}</p>;
    }
    
    export default function App() {
      return (
        <>
          <p>Your current time zone is:</p>
          <Suspense fallback="Loading...">
            <TimeZone />
          </Suspense>
        </>
      );
    }
    

    Tres líneas de estado sustituidas por una. Y el fallback deja de ser null para pasar a ser un Suspense de verdad, que es lo que debería haber sido siempre.

    Además se puede llamar condicionalmente, cosa que no es habitual en las APIs de React:

    function TimeZone({ defaultValue }) {
      if (defaultValue) return <p>{defaultValue}</p>;
      use(browser());
      const localTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
      return <p>{localTimeZone}</p>;
    }
    

    Y dentro de tus propios hooks, que es donde se pone interesante:

    function useBrowserQuery(query, options) {
      if (options.initialData === undefined) use(browser());
      return useQuery(query, options);
    }
    

    Ahí acabas de mover una decisión de renderizado —"esto solo puede resolverse en cliente"— del componente a la capa de datos. El componente ya no sabe nada del entorno.

    Requisito: necesitas un Suspense por encima. Sin él no funciona. Si todavía tratas Suspense como un spinner y no como una herramienta de orquestación, aquí lo desarrollo: fetching paralelo en Next.js con Suspense y Promise.all.


    Context en Server Components: borra el Provider intermedio

    Desde React 19.3, un Server Component puede importar un Context definido en un módulo 'use client' y renderizarlo directamente, sin un componente Provider intermedio.

    Si trabajas con RSC conoces el peaje. Para pasar datos del servidor al árbol de cliente había que crear un componente 'use client' cuya única razón de existir era renderizar el Provider:

    // user-context.js  ('use client')
    export const UserContext = createContext(null);
    export function UserProvider({ currentUser, children }) {
      return <UserContext value={currentUser}>{children}</UserContext>;
    }
    
    // server-component.js
    import { UserProvider } from './user-context';
    export async function Layout({ children }) {
      const currentUser = await getCurrentUser();
      return <UserProvider currentUser={currentUser}>{children}</UserProvider>;
    }
    

    Ahora el Server Component importa el Context directamente de su módulo 'use client' y lo renderiza:

    // user-context.js  ('use client')
    export const UserContext = createContext(null);
    
    // server-component.js
    import { UserContext } from './user-context';
    export async function Layout({ children }) {
      const currentUser = await getCurrentUser();
      return <UserContext value={currentUser}>{children}</UserContext>;
    }
    

    Un fichero menos y, sobre todo, una capa de indirección menos. Si estás montando esto en producción, escribí sobre las decisiones reales de React Server Components que hay detrás de esa frontera server/client: este cambio elimina uno de los puntos de fricción que mencionaba allí.


    Otros cambios de React 19.3: Trusted Types, rendimiento y renombrados

    Tres cosas más que no dan para post pero que conviene saber.

    Trusted Types. React ya no coerciona a string los valores TrustedHTML, TrustedScript y TrustedScriptURL: los pasa tal cual para que el navegador valide la política. Si sirves con Content-Security-Policy: require-trusted-types-for 'script', esto cierra una vía de XSS basado en DOM que antes tenías que tapar tú.

    Rendimiento. Dos cambios, sin cifras publicadas y sin que yo me las invente: las Transitions se renderizan de forma independiente en vez de enredarse en un único render, así que una Transition lenta ya no bloquea a otras que no tienen nada que ver; y las actualizaciones que vienen de eventos de resize se agrupan hasta el siguiente frame. Si tu app tiene layout reactivo al viewport, lo notarás sin tocar nada.

    Renombrados y fixes. useActionState pasa a hablar de "action state" en vez de "form state". Y hay arreglos que probablemente expliquen algún bug que archivaste como "cosas raras": useDeferredValue quedándose con el valor viejo, useSyncExternalStore perdiendo mutaciones, useEffectEvent roto dentro de forwardRef y memo, y la propagación de context en los fallbacks de Suspense.


    ¿Merece la pena actualizar a React 19.3 hoy?

    La nota de release oficial de React 19.3 no documenta ningún breaking change. La instalación es esta:

    npm install react@19.3 react-dom@19.3
    

    Aviso para quien llegue desde los sandboxes de la doc: ahí verás versiones 19.3.0-canary-*. Esas son del entorno de la documentación, no la instrucción de instalación. La estable es 19.3.0.

    Mi recomendación: actualiza y no migres nada todavía. Primero mide cuánto código muerto tienes. Es un ejercicio de veinte minutos y te da la lista de la compra.

    Yo lo hago con un agente: le pido a Claude Code que barra el repo buscando los tres patrones —flags de mounted, wrappers que solo existen para un ref y animaciones de ruta hechas con librería— y que me devuelva un inventario con ubicación y coste, no un PR. Que el agente haga el inventario y la decisión la tomes tú es justo lo que enseño en el curso Construye con IA: de la idea al producto con Claude Code.

    Y si además sigues el ecosistema Angular, merece la pena comparar cómo resuelve cada framework lo mismo. Lo desarrollé en el post sobre Signals en Angular 22 frente a React 19. React 19.3 refuerza esa dirección: el modelo de reactividad no se toca, lo que mejora es qué puedes expresar sin escribir infraestructura.


    Por dónde empezar a migrar a React 19.3

    Abre tu proyecto y busca mounted. Solo eso.

    Cada resultado es un componente que tarda un render extra en aparecer, que probablemente hace flash en producción y que existe porque React no tenía forma de decir "esto es client-only". Ahora la tiene. Ese es el mejor punto de entrada a React 19.3 porque el cambio es local, no rompe nada y se ve en el primer render.

    Cuando termines con esos, ve a por los <div> wrapper. Y deja las transiciones de página para el final, que son las más divertidas y las que más tiempo te van a comer.

    En Dominicode Labs estamos probando estas APIs sobre proyectos reales y compartiendo lo que funciona y lo que no. Y si prefieres verlo en vídeo, lo iré contando en el canal de Dominicode a medida que lo lleve a producción.


    Preguntas frecuentes sobre React 19.3

    ¿React 19.3 rompe algo si actualizo desde 19.2?

    La nota de release de React 19.3 no documenta ningún breaking change. La actualización es npm install react@19.3 react-dom@19.3 y las APIs nuevas son aditivas: ViewTransition y addTransitionType no estaban en la API estable —vivían en los builds experimentales—, browser() es nueva, y que Fragment acepte ref no cambia el comportamiento de los Fragment que ya tienes. Aun así, actualiza en una rama y pasa tu suite de tests: la release incluye arreglos en useDeferredValue, useSyncExternalStore y useEffectEvent, y si tu código dependía sin saberlo del comportamiento defectuoso, ahí es donde lo vas a notar.

    ¿Por qué mi ViewTransition no anima nada?

    Casi siempre por lo mismo: el cambio de estado no está dentro de una Transition. <ViewTransition> solo se activa con startTransition, useTransition, useDeferredValue o una navegación de Suspense. Un setState normal actualiza el DOM sin animar. Antes de tocar el CSS, comprueba que el setState que provoca el cambio está envuelto en una Transition.

    ¿Puedo usar View Transitions de React 19.3 con Next.js o React Router?

    Sí, porque <ViewTransition> es un componente de react y no depende del router. La condición es que el cambio de estado ocurra dentro de una Transition, y las navegaciones de los routers modernos ya lo hacen. Lo que no obtienes automáticamente es la direccionalidad: para animar distinto hacia delante y hacia atrás tienes que llamar tú a addTransitionType('next') dentro del startTransition que dispara la navegación.

    ¿Fragment Refs sustituyen a los refs normales?

    No. Un ref normal apunta a un nodo del DOM y eso sigue siendo lo correcto cuando ese nodo existe. Fragment Refs resuelven el caso en el que necesitas operar sobre un conjunto de hijos y no hay un elemento común que los envuelva, o lo hay solo porque lo metiste tú para colgar el ref. El FragmentInstance que recibes trabaja sobre los hijos de primer nivel: registra eventos, mueve el foco con focus() y focusLast(), conecta un IntersectionObserver o un ResizeObserver con observeUsing(), mide con getClientRects() y hace scroll con scrollIntoView().

    ¿use(browser()) elimina todos los useEffect de mi app?

    No, solo un patrón concreto: el de marcar un componente como client-only. browser() se importa de react-dom, suspende en el servidor y no suspende en el cliente, así que sustituye al trío useState + useEffect + flag mounted. Necesita un Suspense por encima para funcionar. Los efectos que sincronizan con sistemas externos —suscripciones, listeners, integraciones con librerías no-React— siguen siendo useEffect.

    ¿Necesito un framework con Server Components para aprovechar React 19.3?

    Para nada. View Transitions, Fragment Refs y use(browser()) funcionan en cualquier aplicación React; browser() cobra especial sentido si haces SSR, pero no exige RSC. Lo que sí requiere una configuración con Server Components es la mejora de Context, que permite a un Server Component importar un Context definido en un módulo 'use client' y renderizarlo directamente, sin el componente Provider intermedio.

    ¿Cómo instalo React 19.3 y por qué veo versiones canary en la documentación?

    Se instala con npm install react@19.3 react-dom@19.3. Las versiones 19.3.0-canary-* que aparecen en los sandboxes interactivos de react.dev pertenecen al entorno de la propia documentación y no son la instrucción de instalación. En npm, react@19.3.0 y react-dom@19.3.0 son estables desde el 9 de septiembre de 2026.


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

  • Tests E2E autoreparables con Playwright: diagnostica, no parchees

    Tests E2E autoreparables con Playwright: diagnostica, no parchees

    El canal de Slack se llamaba #e2e-alerts. Llevaba cinco meses en silencio, y no porque no llegaran alertas: es que las once personas del equipo lo tenían silenciado.

    Ciento ochenta tests de Playwright. Entre veinte y cuarenta en rojo cada mañana. El ritual era mirar por encima, decir "es flaky" y relanzar el job.

    Cuando entré en ese proyecto me pidieron exactamente lo que estás pensando: montar tests E2E autoreparables, un agente que arreglara solo lo que se rompiera cada noche. Dije que no. Esa negativa es la mitad de este post.

    Antes de decir que no me senté a mirar por qué estaba rojo. Casi todo venía del mismo sitio: un refactor del design system que había renombrado clases CSS, y ciento y pico selectores apuntando a esas clases.

    Pero había un test distinto. El de pagar con código de descuento. Alguien lo había "arreglado" tres semanas antes cambiando getByRole('button', { name: 'Pagar pedido' }) por locator('button').nth(3).

    Verde. Precioso.

    El cuarto botón de esa página era "Seguir comprando". El de pagar llevaba nueve días deshabilitado para cualquier usuario que aplicara un descuento. Nueve días sin que nadie con un cupón pudiera pagar.

    El test lo sabía. Y una persona lo calló a mano en cuarenta segundos.

    Ahora imagina eso mismo, cien veces por semana, hecho por un agente que no firma nada y al que nadie revisa.

    Qué es un test E2E autoreparable (y qué no lo es)

    Aquí está la definición con la que trabajo:

    Un test E2E autoreparable es aquel que, cuando falla, dispara un agente que investiga la traza de ejecución, clasifica la causa y propone un parche revisable con evidencia adjunta. No es el que se modifica a sí mismo hasta ponerse verde.

    En inglés se conoce como self-healing test, y ahí está justo el malentendido: healing no significa que el test se reescriba solo.

    La diferencia no es de matiz. Es de propósito.

    Un test existe para emitir una señal: esto funciona, esto no. Si le das a un agente permiso para editar el test hasta que pase, has construido una máquina de decir "verde". Y una máquina de decir verde vale exactamente cero.

    El valor no está en el auto-arreglo. Está en el auto-diagnóstico. La parte cara de mantener una suite E2E no es escribir el parche —son tres caracteres en un locator—, es averiguar cuál de los cuarenta tests rojos merece un parche y cuáles están gritando que la aplicación se rompió.

    Eso es lo que un agente hace bien. Lo otro es lo que lo hace peligroso.

    No se rompe el test, se rompe el acoplamiento

    Los tests E2E no se degradan solos. Lo que se degrada es el contrato implícito entre tu test y el DOM.

    Escribiste .checkout__actions > button.btn-primary. Nadie firmó que esa clase fuera estable. Un martes alguien migró el botón a otro componente y tu test se enteró en producción.

    En las suites que he auditado, la inmensa mayoría de los rojos son fallos de localización, no de comportamiento: el elemento sigue ahí, hace lo mismo, y el test ya no sabe encontrarlo. Es mi impresión revisando proyectos, no un estudio —pero haz el recuento en tu propia suite y apuesto a que te sale parecido.

    Esa asimetría es la que hace viable un agente. Y también la que lo vuelve inútil si no distingue el resto.

    Antes del agente: locators de Playwright que se puedan reparar

    Si tus locators son frágiles, el agente no reduce la deuda. La automatiza.

    La estrategia de locators de Playwright está construida sobre lo que el usuario percibe, no sobre la implementación. Ese es el punto: un locator basado en rol accesible sobrevive a un refactor de estilos y muere cuando cambia lo que el usuario ve —que es justo cuando quieres que muera.

    // ❌ Se rompe con cualquier refactor de CSS. El agente no puede saber si es grave.
    await page.locator('.checkout__actions > button.btn-primary').click();
    
    // ✅ Se rompe solo cuando cambia lo que el usuario percibe.
    await page.getByRole('button', { name: 'Pagar pedido' }).click();
    await page.getByLabel('Código de descuento').fill('BLACKFRIDAY');
    await expect(page.getByTestId('order-summary')).toContainText('49,90 €');
    

    El orden que sigo es siempre el mismo: getByRole primero, getByLabel para formularios, y getByTestId solo cuando no hay semántica que agarrar —listas virtualizadas, tablas sin cabecera accesible, componentes de terceros.

    Y el atributo de test se declara en la config, no se improvisa por fichero:

    // playwright.config.ts
    import { defineConfig } from '@playwright/test';
    
    export default defineConfig({
      retries: 2,
      reporter: [['html', { open: 'never' }]],
      use: {
        testIdAttribute: 'data-test',
        trace: 'on-first-retry',
      },
    });
    

    Hay un efecto secundario que casi nadie cuenta: escribir los tests por rol accesible te obliga a que la aplicación tenga roles accesibles. Es la misma palanca que uso cuando pongo a un agente a auditar usabilidad con Playwright MCP. El árbol de accesibilidad deja de ser un extra de compliance y pasa a ser la API contra la que testeas.

    La traza es la única señal que el agente puede usar

    Sin traza, el agente lee un mensaje de timeout y adivina. Con traza, compara.

    Esa línea de la config —trace: 'on-first-retry'— es la que separa un diagnóstico de una alucinación. Playwright graba el reintento completo: acciones, peticiones de red, consola y snapshots del DOM antes y después de cada paso. El archivo se guarda dentro de test-results/ y se abre con npx playwright show-trace. La documentación del Trace Viewer explica el formato entero. Verificado con Playwright 1.63, la versión estable en septiembre de 2026.

    Un humano abre esa traza y ve la película. Un agente necesita algo más masticado, porque el DOM crudo de una app real son decenas de miles de tokens de ruido. En las dos páginas donde lo he medido, el árbol de accesibilidad ocupaba entre 2,5 y 28 veces menos que el HTML crudo.

    Lo que le doy es el árbol de accesibilidad en el momento del fallo, que es la misma abstracción sobre la que están escritos los locators. ariaSnapshot() está disponible desde Playwright 1.49; en versiones anteriores tendrás que serializar el árbol a mano.

    Aquí conviene ser honesto: Playwright 1.63 ya escribe por su cuenta un test-results/<test>/error-context.md con una sección # Page snapshot que contiene ese mismo árbol, así que una parte de esto la tienes gratis. El fixture sigue mereciendo la pena por lo que añade encima: la URL exacta del fallo, el nombrado que decides tú y los adjuntos visibles en el reporte HTML, que es lo que después consume el pipeline del agente.

    Este es el fixture:

    // tests/fixtures/diagnostico.ts
    import { test as base, expect } from '@playwright/test';
    
    export const test = base.extend<{ diagnostico: void }>({
      diagnostico: [
        async ({ page }, use, testInfo) => {
          await use();
    
          if (testInfo.status === testInfo.expectedStatus) return;
    
          // Árbol de accesibilidad real en el instante del fallo
          const real = await page.locator('body').ariaSnapshot();
          await testInfo.attach('aria-real.yml', { body: real, contentType: 'text/yaml' });
          await testInfo.attach('url-fallo.txt', { body: page.url(), contentType: 'text/plain' });
        },
        { auto: true },
      ],
    });
    
    export { expect };
    

    Con ese YAML adjunto en el reporte, el prompt del agente deja de ser "arregla este test" y pasa a ser una comparación: esperaba un button con nombre "Pagar pedido"; en el árbol real hay un button con nombre "Confirmar pedido" en la misma posición. Eso ya no es adivinar. Es diffear dos estructuras.

    Y si el elemento no aparece en el árbol bajo ningún nombre, el agente tiene que saber que eso significa algo completamente distinto.

    El triaje de un test E2E autoreparable: tres fallos distintos

    Esta es la sección que sostiene todo lo demás. Un agente que no separa estos tres casos te borra la señal de la suite entera.

    Tipo de fallo Evidencia que lo identifica Qué puede hacer el agente
    A. El DOM cambió, el test no El elemento existe en el árbol con otro nombre, rol o posición. El commit sospechoso toca marcado o estilos. Fallan varios tests que comparten componente. Proponer el parche del locator en un PR. Es el único caso reparable.
    B. El comportamiento cambió a propósito El elemento ya no existe porque el flujo cambió: un paso nuevo, otra ruta, un campo eliminado. El commit toca lógica, no marcado. El fallo encaja con una feature reciente. Nada. Abre un issue con el diagnóstico y etiqueta al dueño de la feature. Actualizar ese test es una decisión de producto.
    C. La aplicación está rota El locator es correcto y el elemento está ahí, pero la acción falla, el estado final es otro o hay 500 en la traza de red. Suele caer un solo flujo crítico. Prohibido tocar el test. Escalar con la traza adjunta. El test está haciendo su trabajo.

    Las tres preguntas que resuelven casi todos los casos:

    ¿El commit tocó marcado o lógica? Un cambio en plantillas y estilos apunta a A. Un cambio en servicios, rutas o estado apunta a B o C.

    ¿Falla un test o fallan veinte? Veinte tests que comparten componente es A casi seguro. Uno solo, en el flujo de pago, con el resto en verde, es C hasta que se demuestre lo contrario.

    ¿El elemento existe con otro nombre o no existe? Existe con otro nombre → A. No aparece en el árbol → B o C, y el agente no decide cuál.

    Fíjate en que ninguna de las tres se responde mirando el test. Se responden cruzando la traza con el diff del commit. Por eso el agente necesita acceso al repositorio, y solo de lectura.

    El agente abre un PR. No commitea.

    El flujo que uso es aburrido, y por eso funciona.

    El job nocturno corre la suite. Si hay rojos, un segundo job lanza el agente con tres entradas: el reporte HTML con sus adjuntos, la traza de cada fallo y el diff de los commits desde el último run verde. Relanzar solo lo caído con npx playwright test --last-failed ahorra minutos de CI.

    El agente no ejecuta el navegador. Lee artefactos. Eso elimina de golpe toda la clase de problemas que aparecen cuando pones a un agente a conducir el navegador en vivo: esperas mal calculadas, referencias caducadas, estado que se le escapa.

    Su salida es una pull request con cuatro cosas: diagnóstico en prosa, clasificación A/B/C con la evidencia que la justifica, el diff del locator y la traza enlazada.

    Y una regla que no se negocia: un locator por test, un test por commit dentro del PR. Si el parche necesita tocar tres líneas para que pase, no es un parche de localización: es un cambio de comportamiento disfrazado. Se rechaza.

    Un humano mergea. Siempre.

    Esa puerta es el mismo mecanismo que describo en la revisión por contrato del código que genera la IA: el agente no gana el derecho a escribir en main por haber acertado ochenta veces seguidas. Si quieres el método entero, con las cláusulas y los límites escritos, está en el ebook gratuito de Revisión por Contrato.

    Los guardarraíles que impiden que la suite se convierta en teatro

    Tres guardarraíles bastan para que un agente de reparación no degrade la suite: que no toque asserts, un tope de cinco tests parcheados por PR y una métrica de bugs escapados a producción. Con eso vas servido.

    El agente no toca asserts. Solo locators y esperas. Un expect(...).toBeVisible() que muta a toBeHidden(), un toContainText con otro importe, un assert borrado: eso es cambiar la definición de correcto, y no es su trabajo. Lo hago cumplir con un check en CI que rechaza el PR si el diff cambia el matcher (toBeVisible, toContainText…) o el valor esperado. El locator que vive dentro de un expect(...) sí se puede tocar; la afirmación sobre qué es correcto, no.

    Tope de tests reparados por PR. Cinco. Si el agente quiere arreglar cuarenta, no ha encontrado cuarenta fallos de localización: ha encontrado un cambio estructural que necesita a una persona pensando diez minutos.

    La métrica que delata el desastre. Cuenta dos números cada mes: locators parcheados por el agente y bugs escapados a producción en flujos que la suite cubre. Si el primero sube y el segundo también, el agente no está manteniendo la suite. La está silenciando.

    Ese segundo número no sale de los tests. Sale de tener observabilidad de verdad, más allá del code review. Sin él pilotas a ciegas con un copiloto que te asegura que todo va bien.

    Un apunte de coste: los fallos de tipo B —comportamiento que cambió a propósito— se detectan mucho más barato una capa por debajo, en tests de componente. Si trabajas con Angular, ese nivel es el que cubro en el curso de Testing en Angular. Cuanto mejor sea tu capa de componente, menos ruido le llega al agente arriba.

    Qué hacer el lunes

    No montes el agente. Todavía no.

    Coge los diez tests que más han cambiado en los últimos tres meses según git log. Son tus diez tests más frágiles. Reescribe sus locators con getByRole y getByLabel, y activa trace: 'on-first-retry' en la config.

    Eso es una tarde. Y probablemente te baje el ruido más que cualquier agente.

    La semana siguiente, cuando algo se ponga rojo, abre la traza y clasifícalo a mano: A, B o C. Hazlo diez veces. Lo que aprendas en esas diez clasificaciones es literalmente el prompt del agente —y no lo puedes escribir antes de haberlo hecho tú.

    Cuando llegue el momento de montarlo, el patrón de agente que produce artefactos revisables en lugar de commits directos es el que enseño en Construye con IA, de la idea al producto con Claude Code.

    Y quédate con esto: el objetivo nunca fue tener la suite en verde. Era saber cuándo dejar de confiar en ella.

    Preguntas frecuentes

    ¿Qué es un test E2E autoreparable exactamente?

    Es un test que, al fallar, dispara un agente que investiga la traza de ejecución, clasifica la causa y propone un parche revisable con la evidencia adjunta. La palabra "autoreparable" describe el diagnóstico automático, no la escritura automática en la rama principal. Si el agente puede modificar el test hasta que pase sin que nadie lo apruebe, lo que tienes no es una suite que se cura sola: es una suite que ha dejado de informarte.

    ¿Por qué no dejar que el agente commitee el arreglo directamente si acierta casi siempre?

    Porque el coste del error no es simétrico. Cien parches correctos te ahorran unos minutos cada uno. Un solo parche incorrecto sobre un fallo de tipo C —la aplicación rota— borra la única señal que tenías de un bug en producción, y encima deja el CI en verde. Cuando ese bug aparezca, tu primer instinto será descartar el área que cubren los tests, precisamente porque estaban pasando.

    ¿Cómo distingue el agente entre un cambio de DOM y una regresión real?

    Cruzando tres evidencias: si el elemento sigue existiendo en el árbol de accesibilidad bajo otro nombre o rol, si el commit sospechoso tocó marcado o lógica, y si el fallo está aislado o afecta a varios tests que comparten componente. Elemento presente con otro nombre, más commit de marcado, más fallo en grupo, apunta a cambio de DOM. Elemento presente, locator correcto y acción que falla apunta a regresión, y ahí el agente no toca nada.

    ¿Sirve esto para tests flaky por timing y no por locators?

    Parcialmente. La traza deja ver si el fallo fue una espera insuficiente o una condición de carrera, y el agente puede proponer sustituir una espera fija por una aserción con reintento automático, que es la forma correcta de esperar en Playwright. Pero el flaky por datos compartidos entre tests o por estado sucio del entorno no se arregla en el test: se arregla aislando los datos de cada ejecución, y eso es trabajo de arquitectura, no de parche.

    ¿Cuánto cuesta esto en tokens si la suite es grande?

    Depende de qué le pases, y ahí está el truco. Si le mandas el DOM crudo de cada fallo, el coste se dispara y además el diagnóstico empeora por el ruido. Pasándole solo el árbol de accesibilidad recortado, la URL y el diff de los commits relevantes, el contexto por fallo se queda en unos pocos miles de tokens. El tope de cinco tests por PR funciona también como tope de gasto.

    ¿Puedo aplicar el mismo triaje sin agente, a mano?

    Sí, y deberías empezar por ahí. La tabla de A/B/C es una herramienta de proceso antes que de IA: obliga al equipo a justificar por escrito por qué un test rojo se convierte en verde. He visto equipos bajar el ruido de su suite a la mitad solo con esa regla y cero automatización. El agente acelera un criterio que ya funciona; no lo inventa.


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

  • htmx 4: qué rompe, qué gana y por qué npm te sigue dando la 2

    htmx 4: qué rompe, qué gana y por qué npm te sigue dando la 2

    Ves el hilo en Hacker News: htmx 4 released. Vas a tu proyecto, corres npm update htmx.org, arrancas el servidor.

    No pasa nada. Sigues en la 2.0.10.

    No es tu lockfile. No es la caché de npm. Es una decisión: el equipo publicó htmx 4.0.0 el 28 de agosto de 2026 bajo el dist-tag next, no bajo latest. Y no piensa moverla hasta principios de 2027.

    El motivo lo dicen ellos con todas las letras: "we do not want to force-upgrade users who are relying on non-versioned CDN URLs for htmx". Miles de páginas apuntan a una URL de CDN sin versión. Cambiar latest sería reescribir el runtime de todas esas páginas de golpe, sin que nadie hubiera tocado un commit.

    Y ahí está lo interesante de esta release, que no va de features.

    Cuando el propio equipo decide no empujarte su major, te está diciendo dos cosas. La primera: rompe lo suficiente como para no fiarse. La segunda, la que te sirve hoy: tienes meses para prepararte, no horas.

    Dos frases de contexto por si no usas htmx. Es la librería que trae HTML del servidor y lo intercambia dentro del DOM usando atributos en el markup, en lugar de mantener un árbol de componentes en el cliente. Es el modelo opuesto al de Angular o React: tu estado vive en el servidor y el navegador solo pega parches de HTML.

    Vamos a lo que cambia.


    Cómo instalar htmx 4 hoy (y por qué npm te da la 2.0.10)

    A día de hoy el dist-tag latest de npm sigue apuntando a htmx 2.0.10, y next apunta a htmx 4.0.0. Por eso npm install htmx.org te instala la 2 aunque htmx 4 lleve publicada desde el 28 de agosto de 2026.

    Todo convive en el mismo paquete:

    npm install htmx.org          # te instala 2.0.10
    npm install htmx.org@next     # te instala 4.0.0
    npm install htmx.org@4.0.0    # explícito, el que yo usaría
    

    Y antes de tocar nada, la herramienta que el equipo publicó junto a la release:

    npx htmx.org@4.0.0 upgrade-check -- ./templates
    

    npx htmx.org@4.0.0 upgrade-check -- ./templates escanea tus plantillas y te marca los atributos eliminados o renombrados. Tarda segundos y te da el tamaño real del problema, que es exactamente lo que necesitas antes de decidir nada.

    La 2.x, por cierto, tiene soporte indefinido. No hay reloj corriendo.


    Cambio 1: la herencia de atributos ahora se pide por escrito

    En htmx 2, un atributo puesto en un padre lo heredaban todos los hijos. Era cómodo hasta que dejaba de serlo, y entonces aparecían hx-disinherit y hx-inherit para apagar y encender esa magia a mano.

    En htmx 4 no se hereda nada salvo que lo digas, con el sufijo :inherited:

    <div hx-confirm:inherited="Are you sure?">
        <button hx-delete="/item/1">Delete</button>
    </div>
    

    Sin ese sufijo, el botón no pregunta nada. Borra.

    Consecuencia directa: hx-disinherit y hx-inherit desaparecen, porque ya no hay nada que desheredar.

    Si necesitas la herencia implícita de htmx 2 mientras migras, htmx.config.implicitInheritance = true la devuelve. A diferencia de fetch(), este cambio sí tiene marcha atrás.

    Este es el cambio que más plantillas rompe, y rompe de la peor manera posible: no lanza errores. Deja de hacer cosas. Tu confirmación desaparece, tu indicador de carga desaparece, tu hx-target heredado deja de aplicarse y el swap aterriza en otro sitio. Todo con la consola limpia.

    Es el mismo patrón que analicé cuando Starlight 0.42 rompió el menú móvil en silencio: los breaking changes que te cuestan dinero no son los que revientan el build, son los que pasan los tests y llegan a producción con la UI medio muerta.


    Cambio 2: fetch() nativo y adiós a XMLHttpRequest

    htmx 4 reescribe todo el motor de peticiones sobre la API nativa fetch() y elimina XMLHttpRequest. Es el único cambio de la major que no se puede revertir por configuración, y la doc es tajante: "All requests use the native fetch() API. This cannot be reverted."

    El motor de peticiones entero está reescrito sobre fetch(). Lo que se lleva por delante:

    • Todos los eventos htmx:xhr:* desaparecen. Cualquier cosa colgada de ellos deja de dispararse.
    • El timeout por defecto pasa a ser de 60 segundos (60000 ms). En htmx 2 era ilimitado. Si tienes un endpoint de informes que tarda dos minutos, en htmx 4 muere solo.

    Ese timeout es de los cambios que más me gustan. Y de los que más incidentes van a provocar la primera semana: un default sano que rompe justo el caso raro que nadie documentó.

    Se revierte con una línea: htmx.config.defaultTimeout = 0.


    Cambio 3: todos los eventos se renombran

    htmx 4 unifica los nombres bajo el patrón htmx:phase:action — fase, acción y, si hace falta, subacción, separadas por dos puntos:

    htmx 2 htmx 4
    htmx:beforeRequest htmx:before:request
    htmx:afterRequest htmx:after:request
    htmx:beforeSwap htmx:before:swap
    htmx:afterSwap htmx:after:swap
    htmx:configRequest htmx:config:request
    htmx:responseError htmx:response:error

    Además, varios errores colapsan en uno: htmx:sendError, htmx:swapError, htmx:targetError y htmx:timeout pasan todos a ser htmx:error. Y los eventos de validación (htmx:validation:*) desaparecen.

    Si tienes una capa de logging o de telemetría colgada de estos eventos, esta es la parte mecánica de la migración: tediosa, pero visible y buscable.


    Otros breaking changes de htmx 4 que no salen en el titular

    Además de la herencia, fetch() y los eventos, htmx 4 trae cinco cambios menores que rompen en silencio.

    Ahora se swapea casi todo. htmx 4 hace swap de todas las respuestas HTTP menos 204 y 304. En htmx 2, los 4xx y 5xx no swapeaban.

    Esto es excelente si devuelves HTML de validación con un 422: se acabó pelearse con la librería para pintar errores de formulario. Y es una bomba si tu 500 devuelve la página de error completa de tu framework, porque ahora esa página entera se te mete dentro del <div> del target. Si necesitas el comportamiento de htmx 2 mientras migras, htmx.config.noSwap = [204, 304, '4xx', '5xx'] lo restaura.

    hx-delete ya no incluye los inputs del formulario que lo envuelve. En htmx 2, cualquier petición que no fuera GET arrastraba los valores del formulario asociado, así que hx-delete se comportaba como hx-post. En htmx 4 la regla excluye también a DELETE, y pasa a comportarse como hx-get: no manda nada. Si dependías de eso:

    <button hx-delete="/item/1" hx-include="closest form">Delete</button>
    

    El historial ya no cachea en localStorage. Al pulsar atrás, htmx hace una petición de red real y swapea en <body> o en [hx-history-elt]. Si quieres el cacheo de antes, hay una extensión hx-history-cache que usa sessionStorage.

    El orden de los swaps out-of-band se invierte. Ahora el contenido principal swapea primero y los OOB después, en orden de documento. Si tenías scripts que asumían el orden contrario, se van a ejecutar contra un DOM distinto.

    Los selectores con espacios en hx-trigger necesitan comillas simples: from:'closest form', target:'.a, .b'.

    Y dos más: el modificador queue de hx-trigger se elimina en favor de hx-sync="this:queue all", y las extensiones ya no se activan con hx-ext — incluyes el script y listo.


    La tabla de atributos eliminados (y el baile peligroso)

    Eliminado Reemplazo
    hx-disable hx-ignore
    hx-disabled-elt hx-disable
    hx-vars hx-vals con prefijo js:
    hx-params evento htmx:config:request
    hx-prompt extensión hx-prompt
    hx-ext incluir el script directamente
    hx-disinherit — (la herencia ya es explícita)
    hx-inherit — (la herencia ya es explícita)
    hx-request hx-config
    hx-history — (ya no hay caché en localStorage)

    Mira las dos primeras filas juntas, porque ahí hay una trampa preciosa.

    hx-disable pasa a llamarse hx-ignore. Y hx-disabled-elt pasa a llamarse… hx-disable. El orden en que hagas ese find & replace decide si tu migración funciona o si conviertes todos tus hx-disable viejos en algo que significa otra cosa.

    Primero hx-disable → hx-ignore. Después hx-disabled-elt → hx-disable. Al revés chocan: el segundo paso renombraría a hx-ignore los hx-disable que acabas de crear.

    Y ancla la búsqueda al nombre exacto del atributo (hx-disable= o la regex \bhx-disable\b). Con un replace de texto plano, el primer paso entra también dentro de hx-disabled-elt y te lo deja como hx-ignored-elt, que no existe. Nadie te avisa.

    Es el tipo de detalle que no se ve en una revisión de PR de 400 líneas de plantillas. Lo mismo que pasaba con los 6 breaking changes de pnpm 12 que sí te afectan: las majors no se rompen en el cambio grande que sale en el anuncio, se rompen en la línea 7 de la tabla de migración.


    Novedades de htmx 4: hx-status, hx-partial y morphing nativo

    htmx 4 añade tres capacidades que en htmx 2 exigían JavaScript o extensiones: hx-status, <hx-partial> y morphing nativo. Porque no todo es pagar el peaje.

    hx-status: comportamiento por código de estado. Acepta código exacto (404), comodín de un dígito (50x) y de rango (5xx), con claves swap:, target:, select:, push:, replace: y transition::

    <form hx-post="/save"
          hx-status:422="swap:innerHTML target:#errors select:#validation-errors"
          hx-status:5xx="swap:none push:false">
    </form>
    

    Esto resuelve de raíz el problema que planteaba antes: los 422 pintan errores donde tú digas y los 5xx no ensucian nada. Es la respuesta declarativa a lo que en htmx 2 era un htmx:beforeSwap con un if dentro.

    <hx-partial>: varios targets desde una sola respuesta. La alternativa a hx-swap-oob, cada bloque con su target y su swap:

    <hx-partial hx-target="#messages" hx-swap="beforeend">
        <div>New message</div>
    </hx-partial>
    <hx-partial hx-target="#count">
        <span>5</span>
    </hx-partial>
    

    Morphing nativo. Nuevos estilos de swap innerMorph y outerMorph con el algoritmo idiomorph, dentro del core: "morph swaps using the idiomorph algorithm. Better for preserving state in complex UIs". Se acabó cargar la extensión para que un swap no te reinicie el foco del input.

    Y una lista rápida de lo demás:

    • Atributos nuevos: hx-action (con hx-method opcional), hx-query (petición QUERY con parámetros en el body), hx-config, hx-ignore y hx-validate.
    • Swaps nuevos: textContent y delete, más los alias before/after/prepend/append.
    • Scroll con sintaxis distinta: hx-swap="innerHTML show:top showTarget:#other" donde antes ponías show:#other:top.

    El core viene además con un paquete de extensiones oficiales agrupadas por propósito:

    • Streaming: hx-sse, hx-ws, hx-multipart.
    • UX: hx-live, hx-pending, hx-prompt, hx-browser-indicator.
    • Rendimiento: hx-preload, hx-history-cache, hx-ptag.
    • Swaps: hx-download, hx-head, hx-targets, hx-upsert.
    • Seguridad: hx-csp.
    • Compatibilidad: htmx-2-compat y hx-alpine-compat.

    Y un bundle htmax.js que empaqueta htmx con las más usadas.

    Esa última categoría, compatibilidad, es la que convierte esta migración en algo realista.


    htmx-2-compat: la vía sensata

    Existe una extensión oficial, htmx-2-compat, que restaura los defaults y los nombres de eventos de htmx 2. Lo que no te devuelve es el motor: las peticiones siguen saliendo por fetch() y los htmx:xhr:* no vuelven de ninguna manera.

    Eso cambia por completo la estrategia. No tienes que elegir entre quedarte en la 2 o reescribir 300 plantillas en un sprint. Puedes:

    1. Subir a htmx 4.
    2. Cargar htmx-2-compat y comprobar qué sigue funcionando igual y qué no.
    3. Ir apagando comportamientos viejos uno a uno, en PRs pequeños, con la app en producción todo el rato.

    Es la diferencia entre una migración y un rewrite.

    Con una condición que no es negociable: necesitas tests que verifiquen el HTML que llega y dónde aterriza. Sin eso, quitar comportamientos de compatibilidad es dar palos de ciego, porque los fallos de htmx 4 son silenciosos casi siempre. Esta es exactamente la mentalidad que trabajo en mi curso de Testing: los tests no están para demostrar que el código funciona, están para permitirte cambiarlo. El framework da igual; el criterio de qué merece un test, no.


    ¿Debo migrar a htmx 4 ahora?

    Tu situación Qué hacer
    Proyecto htmx 2 en producción, estable Corre upgrade-check, guarda el informe, no migres aún
    Proyecto nuevo que empiezas esta semana Empieza en htmx.org@4.0.0 directamente
    Usas URL de CDN sin versión Fíjala a una versión concreta hoy, antes de 2027
    Tienes hx-disable o hx-disabled-elt en plantillas Anota el orden del rename ahora, mientras lo tienes fresco
    No usas htmx Quédate con la decisión del dist-tag, que es lo valioso

    Lo que yo haría esta semana

    Corre esto y guarda la salida en el repo:

    npx htmx.org@4.0.0 upgrade-check -- ./templates
    

    Ya está. No migres hoy. Lo que necesitas ahora es un número: cuántos atributos tuyos están en esa lista. Con ese número decides en enero si es una tarde o un trimestre, y lo decides con datos en vez de con la sensación que te dejó un hilo de Hacker News.

    Y quédate con la lección de fondo, que sirve para cualquier dependencia de tu package.json: latest no significa "la última versión". Significa "la versión que el equipo se atreve a darte por defecto". Cuando esas dos cosas se separan durante seis meses, la distancia entre ellas es el mapa de todo lo que rompe.


    Preguntas frecuentes sobre htmx 4

    ¿Puedo instalar htmx 4 hoy?

    Sí. Está publicada como 4.0.0 desde el 28 de agosto de 2026, solo que bajo el dist-tag next en lugar de latest. Instálala con npm install htmx.org@next o, mejor, fijando la versión con npm install htmx.org@4.0.0 para que no te cambie bajo los pies cuando publiquen la siguiente preview.

    ¿Cuándo pasa htmx 4 a ser la versión latest?

    El equipo ha dicho que htmx 4 tomará el tag latest en algún momento de principios de 2027. La razón de esperar es no forzar la actualización a quienes cargan htmx desde una URL de CDN sin versión, que se actualizarían de golpe sin haber tocado su código.

    ¿Cuánto rompe htmx 4 mi proyecto de verdad?

    Depende de cuántos atributos eliminados uses, y eso lo puedes medir hoy con npx htmx.org@4.0.0 upgrade-check -- ./templates. Los tres focos de dolor son la herencia de atributos, que ahora exige el sufijo :inherited; los eventos, que se renombran todos al patrón htmx:fase:acción; y el swap de respuestas 4xx y 5xx, que antes no ocurría y ahora sí.

    ¿htmx 2 deja de tener soporte cuando la 4 sea latest?

    No. El equipo mantiene la rama 2.x con soporte indefinido. No hay una fecha de fin de vida anunciada, así que quedarte en htmx 2 es una decisión válida y no una deuda técnica con cuenta atrás.

    ¿Merece la pena migrar ya?

    Si arrancas un proyecto nuevo, sí: empieza directamente en la 4 y te ahorras la migración entera. Si tienes algo en producción, la vía razonable es subir a la 4 con la extensión htmx-2-compat, que restaura los defaults de htmx 2, y desactivar comportamientos viejos poco a poco en vez de reescribir todas las plantillas de una vez.

    ¿Qué gano si migro, aparte de estar al día?

    Tres cosas concretas: hx-status, que te deja definir swap, target y select por código de respuesta de forma declarativa; el elemento <hx-partial>, que apunta a varios elementos desde una sola respuesta sin hx-swap-oob; y el morphing con idiomorph integrado en el core mediante los swaps innerMorph y outerMorph, sin extensión externa.


    Si prefieres ver este tipo de análisis en vídeo, con el proyecto delante, lo publico en el canal de YouTube de Dominicode.

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

  • Hooks vs permissions en Claude Code: dónde va el guardrail

    Hooks vs permissions en Claude Code: dónde va el guardrail

    Abrí mi propio .claude/settings.local.json mientras preparaba este post. Buscaba un ejemplo bonito de hooks vs permissions en Claude Code para ilustrar la diferencia. Encontré otra cosa.

    364 reglas allow. Cero reglas deny. Cero ask. Y "defaultMode": "bypassPermissions".

    La primera regla de la lista es Bash(*).

    Bash(*) equivale a Bash: matchea todos los comandos. Las otras 185 reglas de Bash de la lista ya no afinan nada, porque no queda nada que afinar. Y las 178 restantes —WebFetch, PowerShell, Skill— tampoco protegen: una regla allow nunca dice que no, solo evita que te pregunten. Con bypassPermissions puesto, ni eso: no iba a preguntar de todas formas. Una allowlist de 364 líneas que no le dice que no a nada.

    Lo mejor viene ahora. En el .claude/settings.json versionado del mismo repositorio sí hay dos hooks PreToolUse, con matchers Bash y Read|Glob. Funcionan perfectamente. Son hooks de guía y observabilidad — le dicen a Claude por dónde buscar antes de lanzarse a hacer grep por todo el repo — no de seguridad.

    Capa de hooks montada. Capa de permisos abierta de par en par.

    Hace poco escribí, en el post donde probé la blocklist típica y pasaron 16 de 20 comandos destructivos, esta frase exacta: "Revisa el tuyo con una pregunta concreta: ¿hay alguna regla comodín tipo Bash(*) que anule a todas las demás? Si la hay, tu allowlist es decorativa."


    Hooks vs permissions: las dos capas, y cuál manda

    Claude Code tiene dos sitios donde puedes decir "esto no".

    El sistema de permisos: configuración declarativa con reglas allow, deny y ask. Los hooks: scripts tuyos que se ejecutan antes de la llamada a la herramienta y pueden bloquearla. Las dos son piezas del agent harness, esa capa que rodea al modelo y decide qué puede tocar de verdad.

    El sistema de permisos de Claude Code es configuración declarativa —reglas allow, deny y ask en settings.json— que se evalúa antes de ejecutar la herramienta. Un hook PreToolUse es un script tuyo que Claude Code ejecuta antes de la llamada y que puede bloquearla saliendo con código 2. La diferencia que decide dónde va tu guardrail: los permisos no pueden fallar, el hook sí.

    Casi todo el que se preocupa por la seguridad monta un hook. Es lo divertido: escribes código, haces pattern matching, devuelves exit 2 y te sientes ingeniero de seguridad. Si nunca has montado uno, empieza por Claude Code hooks: guardrails, logging y automatización para tus agentes, el tutorial del cómo.

    Este post va del dónde. Y el dónde importa porque las dos capas no tienen la misma autoridad.

    Un hook solo puede restringir, nunca ampliar

    Un hook PreToolUse es una válvula de un solo sentido. Cierra, no abre.

    La documentación de permisos de Claude Code no deja margen a interpretación. Traduzco las dos frases que lo zanjan:

    "Las decisiones de un hook no saltan las reglas de permisos. Claude Code evalúa las reglas deny y ask sea cual sea lo que devuelva un hook PreToolUse: una regla deny que coincida bloquea la llamada, y una regla ask que coincida sigue preguntando incluso cuando el hook ha devuelto allow o ask."

    "Un hook que bloquea también tiene precedencia sobre las reglas allow. Un hook que sale con código 2 detiene la llamada antes de que se evalúen las reglas de permisos, así que el bloqueo se aplica incluso cuando una regla allow habría dejado pasar la llamada."

    Puede vetar lo que tus permisos habrían permitido. No puede rescatar lo que ya han denegado. Si diseñas la seguridad pensando que el hook es "el sitio donde yo decido", la estás montando sobre la capa con menos autoridad.

    La propia documentación sugiere el patrón combinado: para ejecutar todos los comandos Bash sin prompts salvo unos pocos, pon "Bash" en allow y registra un hook PreToolUse que rechace esos concretos. Es un patrón legítimo. Fíjate en para qué lo propone: ergonomía. No suelo de seguridad.

    El hook falla abierto. La regla deny no puede fallar.

    Un hook es un proceso. Y a los procesos les pasan cosas. Esto es lo que ocurre según cómo termine:

    Esto es lo que dice la documentación de hooks según cómo termine el proceso:

    • exit 2 → bloquea. Imprima JSON o no; ni un permissionDecision de allow puede anularlo. El mensaje de bloqueo es la razón del JSON si la hay, y si no, el stderr.
    • exit 0 con JSON válido → manda la decisión del JSON y el exit code se ignora.
    • Cualquier otro exit code, un script que no existe, JSON inválido o un timeout → error no bloqueante. En palabras de la doc: "the action proceeds". La llamada continúa por el flujo normal de permisos y en el transcript aparece un aviso <hook name> hook error.

    Lee otra vez la tercera.

    El día que renombras el script, cambias de máquina, se te cuela una coma en el JSON o el proceso se pasa del timeout, tu "guardrail de seguridad" deja pasar el comando. No para el mundo: escribe un aviso y sigue.

    Incluida la salida 1, la de fallo de toda la vida en Unix: si tu hook revienta con exit 1, el comando pasa.

    En PreToolUse el timeout por defecto es de 600 s. Y todos los hooks que matchean corren en paralelo, así que tampoco hay un orden de ejecución en el que apoyarte.

    Una regla deny no tiene ninguna de esas formas de fallar: no hay proceso, ni exit code, ni ruta a un binario que pueda cambiar. Es configuración que se evalúa antes de ejecutar nada. Si una herramienta está denegada en cualquier nivel, ningún otro nivel puede permitirla — ni --allowedTools en la línea de comandos.

    Falla cerrado por construcción. Por eso el suelo va ahí.

    El sistema de permisos entiende de shell. Tu grep no.

    Cuando escribes un hook con grep haces pattern matching sobre un string. Claude Code no: parsea el comando.

    Los separadores que reconoce son &&, ||, ;, |, |&, & y los saltos de línea. Cada subcomando debe coincidir por separado. Y las reglas deny y ask matchean más allá de una asignación de variable de entorno.

    Dos comandos y las dos capas, lado a lado:

    safe-cmd && rm -rf /
    FOO=bar rm -rf tmp/
    
    Comando Hook con grep Regla de permisos
    safe-cmd && rm -rf / [[ "$cmd" == safe-cmd* ]] → pasa: el string empieza por safe-cmd Bash(safe-cmd *) no da permiso: cada subcomando se matchea por separado
    FOO=bar rm -rf tmp/ grep -E '^rm ' → no matchea: el comando empieza por FOO Bash(rm *) en deny sí coincide: matchea pasada la asignación

    Dos comandos. Dos fallos del hook ingenuo. Cero de las reglas.

    Y hay más parseo que no vas a reimplementar bien. Antes de matchear se despojan los wrappers conocidos: timeout, time, nice, nohup, stdbuf, los builtins command y builtin, y el noglob de zsh. Por eso Bash(npm test *) también cubre timeout 30 npm test.

    Claude Code hasta rechaza tus patrones frágiles por ti: una regla como Bash(command:rm *) sería esquivable con un comando compuesto, así que la ignora y emite un warning al arrancar. Lo correcto es Bash(rm *).

    Los agujeros que sí existen

    Los permisos no son magia. Hay tres sitios donde te toca poner de tu parte.

    Los runners de entorno no están en la lista de wrappers que se despojan: direnv exec, devbox run, mise exec, npx, docker exec. Ejecutan sus argumentos como comando, así que Bash(devbox run *) cubre también devbox run rm -rf .. La regla correcta lleva runner + comando interno, Bash(devbox run npm test), una por cada uno. Tedioso y correcto.

    Los patrones sobre argumentos son frágiles: Bash(curl http://github.com/ *) no cubre las variaciones. Deniega curl y wget y usa WebFetch con WebFetch(domain:github.com).

    Las redirecciones se comprueban como escritura de archivo: el destino de >, >> y 2> se valida contra tus reglas Edit. Bash(git commit *) permite el comando, no el destino. /dev/null no se comprueba.

    Nada de esto lo arregla un hook. Enumerar strings peligrosos es el diseño equivocado, lo escribas en un middleware o en un PreToolUse.

    La sintaxis que casi nadie escribe bien

    Regla Qué matchea
    Bash(npm run build) exactamente npm run build
    Bash(npm run *) npm run build, npm run test --watch, y el escueto npm run
    Bash(ls *) ls -la y ls — pero no lsof
    Bash(ls*) ls -la y lsof
    Read(./.env) leer el .env del directorio actual
    Read(./secrets/**) glob estilo gitignore
    WebFetch(domain:example.com) peticiones a ese dominio

    El espacio antes del * es toda la diferencia entre ls y lsof. El sufijo :* equivale al wildcard final — Bash(ls:*) ≡ Bash(ls *) — pero solo se reconoce al final del patrón: en Bash(git:* push) los dos puntos son un carácter literal.

    Y el * va después del subcomando. Bash(git log *) permite solo git log; Bash(git *) permite todo git. Claude Code te avisa al arrancar si lo escribes antes.

    Queda la precedencia, que mucha gente asume al revés: se evalúa deny, luego ask, luego allow. La primera coincidencia en ese orden decide, y la especificidad de la regla no altera el orden. Una deny amplia como Bash(aws *) bloquea todo lo que coincida, incluida una allow más estrecha como Bash(aws s3 ls).

    Dicho de otra forma: una regla deny no admite excepciones de allowlist. Si necesitas una excepción, no la pongas en deny.

    Hooks vs permissions en Claude Code: qué va en cada capa

    permissions Hooks PreToolUse
    Naturaleza Configuración declarativa Proceso que ejecutas
    Modo de fallo No puede fallar: no hay nada que ejecutar Falla abierto: error, timeout o JSON inválido → la acción continúa
    Autoridad deny es absoluto en todos los niveles Solo restringe; nunca amplía
    Entiende shell Sí: separadores, wrappers, asignaciones, redirecciones Solo lo que tú programes
    Superficie Toda herramienta con regla Solo lo que cubra tu matcher
    Para qué sirve Suelo de seguridad, lo irreversible, secretos Contexto, logging, reescritura, reglas que dependen del estado del proyecto
    Ejemplo Bash(rm *), Read(./.env) Registrar cada comando, avisar si el working tree está sucio, guiar la búsqueda

    El JSON que deberías tener

    {
      "permissions": {
        "defaultMode": "default",
        "deny": [
          "Bash(rm *)",
          "Bash(curl *)",
          "Bash(wget *)",
          "Read(./.env)",
          "Read(./.env.*)",
          "Read(./secrets/**)"
        ],
        "ask": [
          "Bash(git push *)",
          "Bash(docker *)",
          "Bash(npm publish *)"
        ],
        "allow": [
          "Bash(npm run build)",
          "Bash(npm test *)",
          "Bash(git status)",
          "Bash(git log *)",
          "Bash(ls *)",
          "WebFetch(domain:github.com)"
        ],
        "disableBypassPermissionsMode": "disable"
      }
    }
    

    Bash(rm *) en deny cubre FOO=bar rm -rf tmp/ sin que hagas nada. Bash(npm test *) en allow cubre timeout 30 npm test gracias al despojado de wrappers. Y deny bloquea aunque más abajo haya una allow que coincida: no hay forma de escribir la excepción, y esa es justo la propiedad que querías.

    Este es el tipo de decisión que trabajo en el curso Construye con IA: de la idea al producto con Claude Code: antes de darle capacidades a un agente, dejar por escrito qué no puede hacer.

    Entonces, ¿para qué el hook?

    Para lo que los permisos no saben expresar. Contexto: la hora, la rama, si el working tree está sucio. Observabilidad: registrar cada llamada. Guía: decirle por dónde buscar antes de que haga grep por todo el repo, que es lo que hacen los dos hooks de mi repo. Reescritura y guía de comandos. Decisiones que dependen del estado del proyecto, no del string del comando.

    Con un límite que conviene tener delante. En PreToolUse el matcher se compara contra el nombre de la herramienta. "*", "" u omitido matchean todo. Si solo contiene letras, dígitos, _, -, espacios, , y |, es un string exacto o una lista: Bash, Edit|Write. Cualquier otro carácter lo convierte en una expresión regular de JavaScript sin anclar: ^Bash.

    Consecuencia práctica: un matcher "Bash" no cubre PowerShell, ni Write, ni Edit, ni las herramientas MCP (mcp__servidor__tool). Si tu guardrail vive solo ahí, toda esa superficie está descubierta y no te vas a enterar.

    Qué hacer hoy

    Abre tu .claude/settings.local.json y cuenta las reglas deny. Si el número es cero, ya tienes plan para esta tarde.

    Escribe cinco. Solo cinco, y que sean lo irreversible: los secretos y el borrado.

    1. Read(./.env) — que no lea tus claves.
    2. Read(./secrets/**) — ni el resto de secretos.
    3. Bash(rm *) — el borrado, que además cubre FOO=bar rm -rf tmp/.
    4. Bash(curl *) — la exfiltración por red.
    5. Bash(wget *) — la otra mitad de lo mismo.

    Luego quita bypassPermissions y ponle "disableBypassPermissionsMode": "disable". Y si trabajas con equipo, todo eso va en managed settings: la precedencia más alta, no lo anula ningún otro nivel ni los argumentos de línea de comandos.

    Un último detalle que resume el post. Poner disableAllHooks solo en tus settings de usuario no basta: los settings de proyecto del repositorio tienen precedencia sobre los tuyos y pueden volverlo a false. Ni siquiera desactivar los hooks se decide desde donde viven los hooks.

    La autoridad está en la configuración. Tu script es la capa de encima.

    Mi settings.local.json ya tiene reglas deny. Tardé cuatro minutos. Llevaba meses con Bash(*) en la primera línea y un hook precioso que no protegía absolutamente nada.

    Si quieres ver esta capa montada en proyectos reales, con los settings completos y los hooks que sí aportan, lo trabajamos dentro de Dominicode Labs.

    Comportamiento verificado contra la documentación oficial de Claude Code en septiembre de 2026.


    Preguntas frecuentes

    ¿Puedo usar un hook para permitir algo que una regla deny bloquea?

    No. Claude Code evalúa las reglas deny y ask sea cual sea lo que devuelva el hook. Una regla deny que coincida bloquea la llamada aunque el hook haya devuelto allow, y una regla ask sigue preguntando igual.

    Al revés sí funciona: un hook que sale con exit 2 detiene la llamada antes de que se evalúen los permisos, así que bloquea aunque una regla allow la hubiera dejado pasar. El hook solo restringe.

    ¿Qué pasa si mi hook de seguridad falla o el script no existe?

    El comando se ejecuta. Cualquier exit code que no sea 0 o 2, un script inexistente, un JSON inválido o un timeout se tratan como error no bloqueante: la acción continúa por el flujo normal de permisos y en el transcript aparece un aviso <hook name> hook error.

    Por eso un hook no es un buen suelo de seguridad. Una regla deny es configuración, no hay proceso que pueda reventar.

    ¿Basta con poner Bash en deny para bloquear todos los comandos?

    Sí, y hace algo más de lo que esperas. Bash(*) es equivalente a Bash y matchea todos los comandos. Como regla deny, ambas formas eliminan la herramienta del contexto de Claude: el modelo ni siquiera la ve.

    Lo que no puedes es denegar Bash entero y luego abrir excepciones con reglas allow más estrechas. Una regla deny no admite excepciones de allowlist.

    ¿Por qué mi regla Bash(ls *) no permite lsof?

    Porque el espacio antes del asterisco forma parte del patrón. Bash(ls *) matchea ls -la y el escueto ls, pero no lsof. Si quieres cubrir los dos, la regla es Bash(ls*), sin espacio.

    Mismo cuidado con el subcomando: el * va después. Bash(git log *) permite solo git log; Bash(git *) te abre todo git.

    ¿Cómo evito que alguien active bypassPermissions en mi equipo?

    Con permissions.disableBypassPermissionsMode a "disable", y su equivalente permissions.disableAutoMode para el modo auto. En tus settings de usuario ya sirven, pero puestos en managed settings son inanulables: ese nivel tiene la precedencia más alta y no lo tumba ningún otro, ni los argumentos de línea de comandos.

    ¿Dónde pongo las reglas: settings.json o settings.local.json?

    .claude/settings.json se versiona y lo comparte todo el equipo. .claude/settings.local.json es tuyo y solo tuyo: Claude Code lo añade a tus git excludes la primera vez que lo escribe, así que no se sube. Si lo creaste tú a mano, añádelo al .gitignore.

    La precedencia va de mayor a menor: managed settings, argumentos de línea de comandos, settings.local.json del proyecto, settings.json del proyecto y ~/.claude/settings.json de usuario. Con una salvedad que ya conoces: si algo está denegado en cualquiera de esos niveles, ningún otro puede permitirlo.

    Así que el suelo compartido va en el settings.json versionado, donde lo hereda todo el equipo. Tus atajos personales, en el local.


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