Tag: Agentes IA

  • SDLC context engineering: arregla el ciclo, no el prompt

    SDLC context engineering: arregla el ciclo, no el prompt

    El mismo agente. El mismo modelo. Prácticamente el mismo prompt.

    En uno de mis repositorios la tarea salió a la primera. En el otro, el agente se inventó un helper que no existía y escribió los tests con una librería que ese proyecto abandonó hace más de un año.

    No falló el modelo. Falló todo lo que había alrededor del modelo.

    Y eso que hay alrededor tiene nombre: SDLC context engineering. Tu ciclo de desarrollo es la fábrica del contexto que consume el agente, y si la fábrica va mal, da igual cómo escribas el prompt.

    El primer repositorio tiene un CLAUDE.md con las convenciones escritas, una carpeta de decisiones de arquitectura y un índice del contenido previo que el agente puede consultar. El segundo tiene un README viejo y el resto vive en mi cabeza.

    Y ahí está el problema: cuando el contexto vive en tu cabeza, el agente tiene que adivinarlo. Adivinar, en un modelo de lenguaje, se llama alucinar.

    Por eso llevo meses insistiendo en lo mismo: el prompt no es la unidad de contexto. Puedes escribir el prompt más elaborado del mundo, con sus tres adjetivos y su frase en mayúsculas, que si la información que necesita el agente no existe en ningún sitio legible, no va a aparecer porque tú se lo pidas con más énfasis.

    El contexto no se escribe en el prompt. Se fabrica antes, en tu ciclo de desarrollo.

    Opero Dominicode solo: cursos, libros, una plataforma y un canal. No tengo un equipo que rellene los huecos por mí, así que los huecos los tengo que cerrar en el proceso. De ahí sale la idea que más ha cambiado mi forma de trabajar en el último año:

    Tu SDLC no es un proceso para humanos. Es la cadena de montaje que fabrica el contexto que consumen tus agentes.


    Qué es el SDLC context engineering

    El SDLC context engineering es tratar tu ciclo de vida del software como el sistema que fabrica el contexto que consumen tus agentes de IA.

    Cada una de las cinco fases —requisitos, diseño, implementación, code review y documentación— deja de producir artefactos para humanos y pasa a producir artefactos que una máquina puede leer, verificar y ejecutar: una spec con límites, un esquema de validación, una suite de tests como gate, un diff acotado y contexto versionado en el repositorio.

    La diferencia con el prompt engineering es de capa. El prompt engineering optimiza la instrucción de un turno. El SDLC context engineering optimiza la información que esa instrucción tiene disponible, y esa información la produce tu proceso, no tú en el momento de escribir.


    Qué cambia cuando el que lee el proceso es una máquina

    Cuando el que lee tu proceso es una máquina cambia el destinatario de cada artefacto: deja de valer lo que un humano completa con conocimiento implícito y solo cuenta lo que cabe en la ventana de contexto.

    El ciclo de vida clásico producía artefactos para personas: un ticket de tres líneas, la foto de una pizarra, un hilo de Slack, una reunión de refinamiento.

    Todo eso funciona con humanos por una razón que casi nunca decimos en voz alta: una persona rellena los huecos con conocimiento implícito. Sabe que en este proyecto los servicios van en esa carpeta. Sabe que ese campo del modelo está deprecado aunque siga ahí. Y, sobre todo, sabe a quién preguntar cuando algo no cuadra.

    Un agente no tiene a quién preguntar. Solo tiene lo que le entre por la ventana de contexto.

    Así que cada fase de tu ciclo tiene dos versiones posibles: la que produce algo para un humano y la que produce algo que una máquina puede leer, verificar y ejecutar.

    # Fase del ciclo Artefacto para humanos Artefacto para agentes
    1 Requisitos Ticket de 3 líneas spec.md con límites explícitos
    2 Diseño Diagrama en una pizarra Contratos ejecutables que validan
    3 Implementación "En mi máquina funciona" Tests como gate de salida
    4 Code review "A mí me parece bien" Diff acotado + auditor automático
    5 Documentación Wiki de hace tres años Contexto versionado en el repositorio

    La columna de la derecha es tu context engineering. No es un documento aparte que escribes el viernes por la tarde: es el residuo natural de un ciclo bien montado.

    Vamos fase por fase.


    1. Requisitos: del ticket de tres líneas al spec.md

    Qué falla: un ticket ambiguo no le da al agente lo único que de verdad necesita. Y no es la descripción de la funcionalidad: son los límites.

    Esta es la frase que más repito y la que más discusión genera: el alcance no es lo que el agente tiene que hacer, es lo que el agente no puede tocar.

    Un agente al que le pides "añade validación al formulario de registro" y no le dices nada más, se expande. Toca el modelo de datos porque le pareció que hacía falta. Refactoriza el componente de al lado porque estaba feo. Añade una dependencia. Y cuando abres el diff, tienes once archivos modificados y ninguna forma rápida de saber cuáles querías.

    Una spec no necesita ser larga. Una página con cuatro bloques:

    • Contratos de datos. La forma exacta de lo que entra y lo que sale.
    • Archivos afectados. Las rutas concretas que se pueden tocar.
    • Fuera de alcance. Lo que no se toca, escrito explícitamente.
    • Criterio de terminado. Qué comando tiene que pasar en verde.

    Con esos cuatro bloques, una spec entera te cabe en la pantalla:

    # Spec — Validación del formulario de registro
    
    ## Contratos
    - Entrada: { email: string, password: string, acceptedTerms: boolean }
    - Salida: { ok: true } | { ok: false, errors: FieldError[] }
    
    ## Archivos afectados
    - src/features/auth/register-form.tsx
    - src/features/auth/register.schema.ts
    
    ## Fuera de alcance
    - No tocar el modelo de usuario ni las migraciones
    - No añadir dependencias nuevas
    - No refactorizar componentes vecinos
    
    ## Terminado cuando
    - `bun test src/features/auth` pasa en verde
    - `tsc --noEmit` sin errores
    

    Ese tercer bloque es el que mejor retorno da de todo el documento, y es el que casi nunca veo escrito.

    Improvisar aquí no sale gratis, y el coste se puede calcular turno a turno: lo hice en la factura del vibe coding. Si tus specs ya existen pero el agente sigue desviándose, el problema suele estar en uno de estos 7 fallos. Y si quieres saber hasta dónde llevar el enfoque, están los tres niveles de Spec-Driven Development.

    La metodología completa, con las plantillas que uso a diario, está en el libro de Spec-Driven Development.


    2. Diseño: del diagrama en la pizarra a contratos ejecutables

    Qué falla: si la forma de tus datos vive dispersa por el código, el agente inventa propiedades. Y las inventa con una seguridad absoluta, porque estadísticamente user.email es un campo muy razonable aunque en tu proyecto se llame user.contactAddress.

    La solución no es documentar los tipos en un wiki. Es que la definición y la verificación sean el mismo artefacto.

    Un esquema de validación —Zod 4 en el ecosistema TypeScript, pero el principio vale para cualquier stack— hace tres cosas a la vez:

    • Describe la forma de los datos en un sitio único y localizable.
    • La comprueba en ejecución, así que si la descripción miente, algo se rompe y te enteras.
    • Genera el tipo con z.infer, así que la definición y la verificación salen del mismo artefacto y se actualizan a la vez.

    Esa segunda parte es la que lo convierte en contexto fiable. Un diagrama puede quedarse obsoleto en silencio durante dos años. Un esquema que se ejecuta, no: cuando un campo obligatorio cambia de tipo o desaparece, revienta y te enteras.

    Con un matiz que conviene saber, porque es donde la gente se confía: por defecto z.object() descarta las claves que no conoce en lugar de fallar. Si la API empieza a devolver campos nuevos, tu esquema los tira sin decir nada. Para que esa deriva también haga ruido necesitas z.strictObject(). El esquema te protege del campo que falta; del campo que sobra, solo si se lo pides.

    Y no confundas una cosa con la otra: los tipos de TypeScript desaparecen al compilar y no validan nada en ejecución. Evitan bugs antes de desplegar, que no es poco, pero el que comprueba lo que entra de verdad por la API es el esquema.

    Estos patrones —esquemas como contrato, inferencia de tipos y validación en los bordes— son los que desarrollo en el curso de Zod para TypeScript.

    La otra mitad del diseño es el acceso. En vez de pegar el esquema de tu base de datos dentro del prompt cada mañana, expones la fuente y dejas que el agente la consulte cuando la necesite. Eso es lo que resuelven los servidores de Model Context Protocol: el contexto deja de ser algo que copias y pasa a ser algo que se consulta.

    Eso sí, cada servidor que conectas mete sus definiciones de herramientas en la ventana. MCP cambia copiar por consultar, no elimina el coste de contexto: conecta los que uses, no los que tengas.


    3. Implementación: del "en mi máquina funciona" al gate de salida

    Qué falla: preguntarle al agente si ha terminado.

    Te va a decir que sí. No porque mienta, sino porque no tiene forma de saberlo: está evaluando su propio trabajo con exactamente el mismo contexto con el que lo escribió. Si le faltaba una pieza para escribirlo bien, le sigue faltando para revisarlo.

    Necesitas una señal que venga de fuera del modelo. Y la señal más barata que existe es un código de salida.

    El bucle que uso:

    1. El agente escribe primero el test que falla.
    2. Escribe el código mínimo para que pase.
    3. El pipeline ejecuta tipado, tests y lint. Si sale 0, la tarea entra en la cola de revisión. Si no, el agente recibe el error y corrige sin que yo intervenga.

    El gate no tiene que ser un pipeline entero. Un script que encadene los tres comandos ya sirve: si devuelve 0, la tarea pasa; si no, el agente recibe el error y sigue solo.

    {
      "scripts": {
        "gate": "tsc --noEmit && bun test && bun run lint"
      }
    }
    

    Lo importante no es que sea TDD de manual. Es que la condición de parada la decide un proceso externo y no una frase del agente. Mientras la puerta de calidad seas tú leyendo la terminal, no has automatizado nada: solo has cambiado de sitio el cuello de botella.

    El flujo completo de validar código generado antes de mergear lo desarrollé en TDD con IA. Y si lo que quieres es probar al propio agente en CI —no solo al código que produce— eso es un test harness, que es una pieza distinta.

    Y ojo con el nivel de la suite, porque aquí hay un efecto perverso: unos tests flojos no son neutros. Le dan al agente permiso para dar por terminado un trabajo a medias, con la ventaja de que ahora el sello de aprobado es automático.


    4. Code review: del "a mí me parece bien" al diff acotado

    Qué falla: el volumen. Un agente produce en veinte minutos más código del que puedes revisar con atención en una tarde.

    Y aquí hay una trampa que cuesta ver: la calidad de tu code review se decide en la fase 1, no en la fase 4. Un diff de once archivos es muy difícil de auditar bien, y la razón por la que toca once archivos es que la spec no dijo cuáles no tocar. Cuando el alcance está escrito, el diff sale acotado solo, y revisarlo pasa de ser una tarde a ser un rato.

    Con el diff ya acotado, la revisión se reparte en dos filtros:

    • El automático, primero. Tipado, tests, lint y un auditor que mire el diff antes que tú. Lo que no pasa esos gates no llega a tus ojos. Cómo montarlo en el pipeline lo detallé en revisiones de código con IA en CI/CD.
    • El tuyo, después, y solo para lo que la máquina no puede ver. Que la abstracción elegida sea la correcta. Que no haya duplicado algo que ya existía. Que el error se maneje donde tiene sentido y no donde resultaba cómodo.

    Esa segunda lista es más larga de lo que parece, y hay fallos del código generado por IA que un code review directamente no ve. Los tests cubren la corrección. Tú cubres el criterio.

    El checklist que uso para auditar diffs generados por IA antes de mergear está en el ebook gratuito Revisión por Contrato.


    5. Documentación: del wiki muerto al contexto versionado

    Qué falla: guardar la arquitectura en herramientas que el agente no puede abrir.

    El contexto tiene que vivir en el repositorio, al lado del código y bajo control de versiones, en cuatro capas de artefactos de contexto para agentes que hacen cosas distintas:

    • CLAUDE.md o AGENTS.md en la raíz. Convenciones, comandos de build, qué no se toca. AGENTS.md es un formato abierto supervisado por la Agentic AI Foundation, bajo la Linux Foundation, y lo usan ya más de 60.000 proyectos open source. Es lo primero que lee el agente al arrancar y lo que evita la mayoría de los "esto no va aquí".
    • docs/adr/ con decisiones de arquitectura. Markdown ligero que explica por qué se decidió algo, no solo qué se decidió. Sin el porqué, el agente deshace tus decisiones creyendo que mejora el código.
    • Un índice consultable del conocimiento previo. Para que pueda buscar en lo que ya existe sin que le metas el proyecto entero en la ventana.
    • Un mapa de dependencias del repositorio. Qué depende de qué. Es la diferencia entre un agente que cambia una función y otro que sabe qué se rompe al cambiarla, y va de graph engineering.

    Con una advertencia importante, porque es el error clásico de quien descubre esto: más contexto no es mejor contexto. Llenar la ventana de documentación irrelevante degrada las respuestas igual que no tener nada, solo que gastando más. Cómo estructurar esa memoria para que sume está en context engineering aplicado a agentes, y qué pasa cuando la conversación se alarga demasiado, en context drift.


    La regla del eslabón más débil de tu SDLC

    La regla del eslabón más débil dice que tu ciclo rinde lo que rinda su fase peor: da igual lo bien que hagas las otras cuatro, el resultado del agente lo marca la fase rota.

    Por eso esta es la parte práctica, la que decide por dónde empezar mañana:

    • Specs impecables sin gate de tests: el agente escribe muy rápido algo que nadie valida.
    • Tests excelentes con tickets ambiguos: validas a la perfección la funcionalidad equivocada.
    • Todo bien montado y el conocimiento en tu cabeza: cada mañana empiezas de cero.

    Así que no empieces por la fase que más te apetece, que suele ser la que ya haces bien. Empieza por la que te está costando dinero ahora mismo. Este diagnóstico lo resuelve en un minuto:

    Lo que te pasa con el agente Fase que tienes rota
    Se sale del alcance y toca archivos que no debía 1. Requisitos
    Inventa campos, funciones o rutas que no existen 2. Diseño
    Dice que ha terminado y no funciona 3. Implementación
    Los diffs son tan grandes que no los revisas 4. Code review
    Repite errores que ya corregiste la semana pasada 5. Documentación

    Ese último síntoma es el más frecuente y el que más gente confunde con un problema de memoria del modelo. No lo es. Es que la corrección se quedó en el chat en vez de acabar en un archivo del repositorio.


    Lo que no debes hacer

    Documentarlo todo.

    Es la reacción típica cuando alguien entiende esta idea: se pasa un fin de semana escribiendo un CLAUDE.md de cuarenta secciones y una carpeta de ADRs preciosa. Tres meses después, la mitad ya no es verdad.

    Y contexto desactualizado es peor que no tener contexto, porque el agente lo obedece. Un archivo que dice que los servicios van en una carpeta que ya no existe no es un documento inútil: es una instrucción activa para hacerlo mal.

    La regla que aplico: si no lo vas a mantener, no lo escribas. Es preferible un archivo de quince líneas verdaderas que uno de doscientas donde no sabes cuáles siguen siéndolo.

    Tampoco todo proyecto necesita este aparato montado. Hay casos concretos en los que el enfoque de spec te frena, y conviene reconocerlos antes de meter ceremonia donde no hace falta.


    Los 3 cambios para tu próximo ticket

    No hace falta rehacer la metodología de tu equipo. En la próxima tarea que delegues:

    1. Escribe el "fuera de alcance". Una línea diciendo qué archivos no debe tocar el agente. Es el cambio con mejor retorno de esta lista.
    2. Pon un gate automático. Aunque sea solo tsc --noEmit y los tests. Que la respuesta a "¿ha terminado?" la dé un código de salida y no una frase.
    3. Mueve una convención de tu cabeza al repositorio. Una. La que más veces has tenido que repetirle al agente esta semana.

    Con esos tres, el siguiente prompt que escribas tiene muchas más probabilidades de salir a la primera sin que le cambies ni una palabra. Porque no habrás mejorado el prompt: habrás mejorado la fábrica que lo alimenta. Eso es SDLC context engineering.

    El flujo completo, de la idea al producto con herramientas agénticas, lo enseño paso a paso en el curso Construye con IA con Claude Code.

    Y si quieres ver los artefactos reales —specs, gates y archivos de contexto de proyectos que están en producción— eso es lo que compartimos cada semana en Dominicode Labs.

    Deja de buscar el prompt mágico. Arregla la fase que tienes rota y el contexto se arregla solo.


    Preguntas frecuentes

    ¿Qué es exactamente el SDLC context engineering?

    Es tratar tu ciclo de vida del software como el sistema que fabrica el contexto de tus agentes de IA. En lugar de escribir prompts cada vez más largos, haces que cada fase del ciclo —requisitos, diseño, implementación, revisión y documentación— deje un artefacto que una máquina pueda leer y verificar: una spec con límites, un esquema de validación, una suite de tests, un diff acotado y contexto versionado en el repositorio.

    ¿Esto no es lo mismo que el prompt engineering?

    No, y la diferencia es de escala. El prompt engineering trabaja sobre la instrucción concreta que escribes en un turno. El context engineering trabaja sobre la información que esa instrucción tiene disponible, y esa información la produce tu proceso, no tú en el momento de escribir. Un buen prompt sobre un ciclo roto sigue dando resultados malos, solo que con mejor redacción.

    ¿Por dónde empiezo si tengo las cinco fases mal?

    Por el síntoma que estés sufriendo ahora, no por el orden numérico. Si el agente se sale del alcance, empieza por la spec. Si inventa campos que no existen, por los contratos de datos. Si dice que ha terminado y no funciona, por el gate de tests. La cadena rinde lo que rinda su fase peor, así que arreglar la que más te está costando da más retorno que mejorar la que ya funciona.

    ¿Hace falta usar Spec-Driven Development para esto?

    No es obligatorio, pero la fase de requisitos es la que más impacto tiene sobre las otras cuatro, y SDD es la forma más ordenada de resolverla. Puedes empezar con algo mucho más ligero: una línea de "fuera de alcance" en el ticket ya cambia el comportamiento del agente. Y hay casos concretos en los que el enfoque de spec te frena en vez de ayudarte, así que conviene reconocerlos antes de montar ceremonia.

    ¿Cuánto contexto es demasiado contexto?

    El que no puedas mantener actualizado. Un archivo de contexto que ya no refleja la realidad no es neutro: el agente lo obedece y hace las cosas mal con total seguridad. La medida correcta no es cuántas páginas tienes, sino cuántas líneas puedes garantizar que siguen siendo ciertas hoy. Además, llenar la ventana de contexto irrelevante degrada las respuestas y encarece cada turno.

    ¿Qué documentación para agentes de IA hace falta de verdad en un repositorio?

    Cuatro capas y nada más: un CLAUDE.md o AGENTS.md en la raíz con convenciones y comandos, una carpeta docs/adr/ con el porqué de las decisiones de arquitectura, un índice consultable del conocimiento previo y un mapa de dependencias del repositorio. Todo versionado junto al código. Lo que no esté en el repositorio, el agente no lo puede abrir.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de 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.

  • 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.

  • 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.

  • 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.

  • Revisar código generado por IA: el método Revisión por Contrato

    Revisar código generado por IA: el método Revisión por Contrato

    Una noche estuve casi dos horas revisando una pull request.

    No la escribí yo. La escribió el agente, en dos minutos.

    Y ahí me quedé, con el diff abierto a las tantas, leyendo línea por línea un código que no había escrito, buscando el fallo que sabía que estaba en alguna parte.

    Dos minutos de generación. Ciento diez de revisión.

    Revisar código generado por IA se había comido entero el tiempo que la IA me iba a ahorrar.

    Nos vendieron que la IA nos iba a quitar trabajo, y es verdad a medias, que es la peor forma de ser verdad. Te quitó el trabajo de escribir. Te dio el trabajo de auditar.

    Antes escribías cuatrocientas líneas en dos horas. Ahora las lees en dos horas.

    Por qué revisar código generado por IA cansa más que escribirlo

    Revisar código generado por IA cansa más que escribirlo porque no sigues un razonamiento: verificas cuatrocientas afirmaciones independientes, una a una, sin ningún hilo que las sostenga.

    Cuando escribes esas cuatrocientas líneas, el modelo mental se construye contigo. Entender es un subproducto gratis de haberlas escrito.

    Cuando las revisas, tienes que reconstruir ese modelo desde fuera, deduciendo la intención a partir del resultado. Y ahí está el detalle:

    Del otro lado no había ningún modelo mental.

    Tu compañero, cuando escribió aquella función rara, tenía un motivo. Malo o bueno, pero un motivo, y podías preguntárselo. El agente produjo el token más probable dadas las circunstancias. Estás reconstruyendo una intención que nunca existió.

    Por eso cansa distinto, y por eso no mejora con la práctica: no hay nada que aprender, solo cuatrocientas comprobaciones que hacer.

    Y lo peor no es el tiempo. Es que nunca sabes del todo si se te ha colado algo, porque en la línea 230 aflojaste y lo sabes. O lo lees entero con la misma atención en la 400 que en la 12, o lo mergeas con el nudo en el estómago. No hay tercera.

    Y ya sabes cuál de las dos gana casi siempre: en cinco proyectos de gran escala, el 64,7% de las pull requests se aprueba sin un solo comentario.

    El problema no era el agente

    Yo estuve meses culpando al modelo. Cambié de herramienta tres veces y escribí prompts cada vez más largos, con más reglas, más ejemplos y más mayúsculas.

    Mejoraba un poco. Nunca lo suficiente.

    Hasta que caí en lo que estaba delante desde el principio: el problema no era el agente, era que yo era la única verificación del sistema. Entre el código generado y producción no había nada más que mis ojos cansados.

    Eso no lo arregla un modelo mejor. Un modelo mejor te da código correcto más a menudo, pero no cambia quién tiene que comprobarlo.

    De hecho, cada mejora en la velocidad de generación empeora tu situación. Si el agente pasa de cuatrocientas líneas a ochocientas en el mismo rato, tú no has ganado nada: has doblado la cola de revisión. La única parte del proceso que no escala eres tú, y todo el mundo está optimizando las otras.

    Por qué tu spec no lo arregló

    Aquí es donde la mayoría me dice que ya probó lo de escribir specs y lo dejó.

    Yo también. Y no es que las specs sean inútiles: es que escribiste la spec para el agente, no para la verificación.

    Mira tus criterios de aceptación de la última vez. “El endpoint debe ser rápido.” “Maneja bien los errores.” “No rompas nada.” Frases que ninguna máquina puede rechazar. Una spec que nadie comprueba es documentación, y la documentación no ha frenado un bug en la historia de esta profesión.

    Un contrato es una especificación contra la que algo puede fallar. Fallar de verdad: salir con código distinto de cero, poner el CI en rojo, parar la cosa antes de que llegue a ti.

    Es la misma diferencia que ya conoces entre un README que dice “recuerda formatear antes de commitear” y un hook que no te deja commitear sin formatear. Los dos expresan la misma norma. Uno confía en que alguien se acuerde.

    Tu spec era un README muy bien escrito. Lo que necesitas es el hook.

    Así que coge cualquier línea de cualquier spec tuya y pregúntate esto:

    ¿Puedo escribir algo que compruebe esto sin mí?

    Si la respuesta es sí, tienes una cláusula. Si es no, tienes una intención. Las intenciones no se tiran —orientan al agente y algo aportan—, pero no cuentan: no van a rechazar nada y no puedes apoyarte en ellas para dejar de leer el diff entero.

    Cuando pasas tu spec entera por esa pregunta suele salir algo incómodo: de veinte líneas, diecisiete eran intenciones.

    Si al hacer el recuento te sale un número parecido, el problema no es que escribas mal specs: son los siete fallos típicos que hacen que una spec no aguante delante de un agente, y casi todos se arreglan con la misma pregunta.

    Ahí está tu tiempo de revisión. Y convertir esas diecisiete es lo que llamo Revisión por Contrato: tres piezas en un orden que importa.

    La Revisión por Contrato es un método para revisar código generado por IA sin leer el diff entero. Consta de tres piezas: conviertes las intenciones de tu spec en cláusulas que una máquina puede rechazar (contrato), declaras por escrito dónde el agente no puede escribir (carril) y dejas que un conjunto de comandos ejecutables emita el resultado (veredicto). Lo que tú revisas después es el veredicto y el contrato, no las cuatrocientas líneas.

    1. Contrato: qué se construye

    La conversión desde tu spec actual es bastante mecánica:

    Spec (describe) Contrato (se puede incumplir)
    “El endpoint debe ser rápido” p95 < 200 ms en el test de carga del CI
    “Maneja bien los errores” Todo path de error devuelve un tipo del enum AppError
    “No rompas nada” La suite existente pasa sin cambios en sus asserts
    “Sigue las convenciones” lint y typecheck en verde, sin excepciones nuevas

    Hay dos contratos, y confundirlos es el error más común. El AGENTS.md es el contrato permanente del repositorio: lo que es cierto para cualquier tarea que se haga aquí. La spec es el contrato de esta tarea concreta, nace con el issue y muere con la pull request. Si metes lo de la tarea en el AGENTS.md, envejece fatal y en dos meses nadie se fía de lo que dice.

    Para la parte de la tarea tengo publicada la skill que uso yo, sdd-creator: obliga al agente a escribir spec.md, plan.md y tasks.md antes de tocar código, y funciona igual en Claude Code, Codex, Cursor o Gemini.

    Aquí vamos con el AGENTS.md, que es el que más rinde por línea escrita. Esta es la primera mitad:

    # AGENTS.md
    
    API de facturación interna. Emite y consulta facturas para el equipo de
    operaciones. No es público: todo el tráfico entra por el gateway.
    
    ## Stack
    
    - **Lenguaje:** TypeScript 7, Node 24 LTS
    - **Framework:** Fastify 5
    - **Gestor de paquetes:** pnpm — derivado de `pnpm-lock.yaml`. No uses otro.
    
    ## Convenciones
    
    - Los handlers no hablan con Prisma. Pasan por un servicio en `src/services/`.
    - Todo error de dominio es un `AppError`. No se lanzan strings ni `Error` pelado.
    - Los tests van junto al fichero que prueban, como `*.test.ts`.
    
    ## Definición de terminado
    
    Una tarea está terminada cuando:
    
    1. El bucle corto pasa en verde.
    2. El bucle largo pasa en verde.
    3. Cada criterio de aceptación de la spec tiene evidencia: qué comando lo
       demuestra y cuál fue su salida.
    4. El diff no contiene nada que la spec no pidiera.
    

    Las convenciones no las inventes: ábrete tres ficheros del repo y escribe lo que ya se hace. Una convención impuesta desde fuera que el código existente incumple es la peor línea que puedes meter ahí, porque el agente la seguirá y su código no se parecerá a nada de lo que hay alrededor.

    Y el punto 4 merece párrafo propio, porque es el que casi nadie escribe y el que más caro sale.

    Le pides al agente que arregle un bug del IVA. Arregla el bug. Y de paso renombra dos variables, extrae un helper, actualiza un comentario y reordena los imports de tres ficheros. Puede que hasta sean mejoras, pero ninguna de esas líneas está cubierta por ningún contrato: nadie las pidió, nadie definió cuándo estarían bien, y ahora están en tu diff obligándote a leerlas.

    Código de más es código sin contrato. Esa línea sola recorta el diff medio de una forma que se nota la primera semana.

    2. Carril: por dónde no puede salirse

    Piensa en la última vez que un agente te dejó algo raro. Ajustó el assert de un test que fallaba. Añadió una dependencia entera para no escribir tres líneas. Metió un as any. Marcó un test lento como skip. Tocó un fichero de despliegue.

    Ninguno de esos es un fallo de razonamiento. En todos entendió perfectamente lo que le pediste.

    La mayoría de los desastres que te va a dar un agente no son de lógica. Son de alcance.

    No hace trampas: hace lo que le pediste por el camino más corto que encontró. Le pediste que los tests pasaran. No le pediste que el código funcionara. Casi siempre coinciden, por eso vivimos tranquilos. Cuando dejan de coincidir, el camino corto es tocar el test.

    Y de aquí sale lo que de verdad importa:

    Si el agente puede modificar la cosa que lo comprueba, no tienes verificación. Tienes teatro.

    Los tests, el linter, el CI — todo eso son ficheros del repositorio, dentro de su radio de acción. Salvo que digas lo contrario, el que recibe el veredicto tiene permiso de escritura sobre quien lo emite. Ningún juzgado funcionaría así.

    ## Límites
    
    Sin permiso explícito, el agente no toca:
    
    - `prisma/migrations/` ni el esquema. Una migración se revisa a mano, siempre.
    - `.github/workflows/`, `Dockerfile` ni nada de despliegue.
    - `package.json`: no se añaden ni se actualizan dependencias. Si hace falta
      una, para y pregunta.
    - `.env`, `.env.*` ni ningún fichero con credenciales.
    - Los asserts de los tests que ya existen. Añadir tests nuevos, sí. Cambiar
      los que ya estaban, no.
    - `src/lib/money.ts`. Es aritmética de céntimos y ya nos ha mordido dos veces.
    

    El “para y pregunta” es una salida y hace falta: un límite sin salida se convierte en un agente bloqueado o, peor, en un agente que se lo salta.

    La línea de los asserts es la que protege al verificador. No prohíbe tocar los tests: prohíbe cambiar los que ya estaban. Añadir cobertura nueva puede y debe; aflojar la existente para que su trabajo pase, no.

    Y la última línea es la que hace creíbles a todas las demás. money.ts no está ahí por una regla general, sino porque ese fichero ya mordió dos veces. Las cuatro primeras las copias de cualquier plantilla; esa la escribes tú. Tu repo tiene dos o tres. Ya sabes cuáles son.

    Ahí está el cambio de postura que ordena todo lo demás: dejas de pedirle al agente que se porte bien y montas un sitio donde portarse mal se detecta solo. Un prompt es una petición y depende de que el modelo esté teniendo un buen día. Un límite es una propiedad del sitio donde trabaja.

    Eso sí, sé honesto con lo que es un fichero markdown: una señal, no una valla. Los límites de verdad viven en tres capas — declarado (el AGENTS.md), impedido (permisos y hooks que rechazan escrituras fuera del alcance) y detectado (CI y protección de rama). La primera cuesta diez minutos y quita la inmensa mayoría de las desviaciones. Si alguien te vende que un markdown le pone puertas a un proceso con acceso de escritura a tu disco, desconfía.

    3. Veredicto: quién dice que está bien

    Un veredicto no es una opinión. Una opinión es lo que da un linter cuando sugiere, o lo que das tú a las once de la noche cuando dices “bueno, tiene buena pinta”.

    Un veredicto es un proceso que termina en dos estados y ninguno más. No admite matices y no cambia según lo cansado que estés.

    Y no lo emite una cosa. Lo emiten cinco:

    Capa Pregunta que responde
    Build ¿Esto compila?
    Tipos ¿Las piezas encajan entre sí?
    Lint ¿Se parece al resto del código de esta casa?
    Tests ¿El comportamiento sigue siendo el que era?
    Criterios de aceptación ¿Hace lo que la spec pidió?

    Las cuatro primeras ya las tienes: están en tu repo desde antes de que existieran los agentes. La quinta es la que casi nadie tiene, y es la que convierte un montón de comandos sueltos en un harness, porque conecta el contrato con algo que se ejecuta.

    Un aviso sobre la palabra “harness”, que se usa para dos cosas distintas: aquí es el conjunto de comandos que verifica el código que tu agente escribe. Si lo que quieres es probar el agente en sí —tools falsas, presupuesto de tokens, trazas reproducibles en CI—, eso es otro montaje y lo explico en el test harness para agentes de IA.

    Esa quinta capa es la que tengo automatizada en ai-workflow-kit —v2.5.0 en npm a septiembre de 2026—, que se instala con npx ai-workflow-kit. El plan de la tarea lleva una casilla por paso, y cada casilla lleva detrás el comando que la demuestra: verify la marca solo cuando ese comando sale con código cero. Lo que queda escrito en el fichero es lo que se demostró, no lo que el agente dijo que había hecho.

    El harness tiene dos velocidades, y esa decisión que parece técnica es la que decide si el sistema se usa o se abandona. El bucle corto lo ejecuta el agente después de cada cambio y tiene que bajar de sesenta segundos; si tarda más, hace tandas más largas entre comprobaciones y cuando algo falla ya no sabes cuál de los quince cambios lo rompió. El bucle largo se ejecuta una vez, antes de abrir la PR.

    Y no, no puedes meterlo todo en el corto por si acaso. Un bucle corto de ocho minutos no es exhaustivo: es un bucle que nadie ejecuta. Los sesenta segundos son el umbral por debajo del cual la gente no busca la forma de esquivarlo.

    ## Verificación
    
    Estos comandos están ejecutados y comprobados. Son el harness: si uno falla,
    el trabajo no está hecho.
    
    ### Bucle corto — después de cada cambio
    
        pnpm typecheck      # 4s
        pnpm lint           # 6s
        pnpm test:unit      # 18s
    
    ### Bucle largo — antes de abrir la PR
    
        pnpm build          # 40s
        pnpm test           # 2m 10s
        pnpm test:e2e       # 3m 30s
    
    ### Rojos conocidos
    
    - `pnpm test:e2e` falla 2 de 34 en `emision-factura.e2e.ts` desde el cambio
      del proveedor de firma. Es anterior al agente. Si falla cualquier otro,
      lo rompiste tú.
    

    Si te llevas una sola línea técnica de este post, que sea esta: nunca metas en el harness un comando que no hayas ejecutado.

    El agente lee el fichero, ve pnpm test:integration, lo lanza, el comando no existe, el error es raro y decide seguir adelante. Él cree que está verificado. Tú crees que está verificado. Nadie ha comprobado nada. Has empeorado tu punto de partida y encima duermes mejor.

    Los tiempos anotados al lado de cada comando tampoco son decoración: son lo que te permite saber dentro de seis meses si el bucle corto sigue siendo corto. Los harness no se rompen de golpe, se degradan un comando cada vez.

    Y los rojos conocidos son lo que más me costó aceptar. ¿Qué haces con un comando importante que hoy falla? La tentación es dejarlo fuera hasta arreglarlo. No lo hagas: ponlo y documenta que está en rojo. Un harness honesto con dos rojos vale más que uno verde de mentira. Además, si el agente sabe qué estaba roto antes de empezar, distingue lo que rompió él de lo que ya estaba roto, en vez de ponerse a investigarlo y a veces a “arreglarlo”.

    Qué cambia el martes por la mañana

    Le pides una feature y el agente rompe el contrato — pongamos que dos tests existentes fallan.

    Sin harness, eso te llega como una PR de cuatrocientas líneas y tú descubriéndolo en el minuto cuarenta. O peor, no descubriéndolo.

    Con harness, el bucle corto se pone rojo a los veintiocho segundos y el agente sabe exactamente qué rompió, porque el rojo tiene nombre y no está en la lista de rojos conocidos. La mayoría de las veces lo arregla solo. Y cuando no puede, lo que te llega no es un diff: es una frase — no puedo cumplir esta cláusula sin tocar lo que dijiste que no tocara.

    Mi tiempo medio de revisión pasó de una hora cincuenta a veinte minutos. A cuatro PRs por semana son seis horas a la semana. No lo redondeo a “cinco veces más rápido” porque los porcentajes bonitos son lo primero que hace desconfiar: es un número mío, medido en mi repo. Tú tendrás el tuyo.

    Pero los veinte minutos siguen ahí, y aquí es donde muchos esperan que diga “y ya no revisas nada”. Lo que cambió es en qué se te van:

    1. Miras el veredicto. Qué pasó, qué falló, qué se saltó. Treinta segundos.
    2. Lees el contrato, no la implementación. Cuarenta líneas. Y la pregunta ya no es “¿está bien este código?” sino “¿pedí lo correcto?”, que es muchísimo mejor pregunta y que solo puedes responder tú.
    3. Lees el diff, pero apuntando a las zonas donde el contrato no llega: si el nombre encaja con el dominio, si la solución es la adecuada para este proyecto. Ahí es donde viven los fallos que un code review no puede ver —N+1, fugas de recursos, race conditions—, y por eso ese tercer paso no lo puedes borrar del proceso.

    Ese tercer punto es tu trabajo de verdad, y es el que la IA no te va a quitar. Nunca fue leer cuatrocientas líneas buscando un null. Era decidir si lo que se construyó tenía sentido.

    Qué hacer hoy

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

    1. Pasa tu última spec por la prueba de una línea. Cuenta cláusulas e intenciones. Ese número explica tu tiempo de revisión mejor que cualquier otra cosa.
    2. Ejecuta tus comandos de verificación, uno a uno, apuntando lo que tarda cada uno. De ahí salen tu bucle corto, tu bucle largo y tus rojos conocidos.
    3. Escribe el AGENTS.md con esas secciones: stack, convenciones, definición de terminado, límites y verificación. Media página. El punto 4 de la definición de terminado no te lo saltes.
    4. Añade una línea de carril que sea tuya. El fichero que ya te mordió. Esa es la que hace creíbles a las otras cinco.

    El AGENTS.md no hace falta que lo escribas mirando a una pantalla en blanco: lo tienes entero, con las cinco secciones y los comentarios de por qué está cada línea, en el ebook gratuito de El método Revisión por Contrato. Treinta páginas, sin coste.

    Si prefieres ver el AGENTS.md, el harness y los límites montados sobre un proyecto real en vez de partir de una plantilla, ese es justo el recorrido de Construye con IA: de la idea al producto con Claude Code.

    Un apunte que te ahorra una tarde: AGENTS.md es un estándar abierto, no algo que traigan todas las herramientas. En su lista de compatibilidad, consultada el 6 de septiembre de 2026, están Codex, Cursor, el agente de codificación de Copilot, Gemini CLI o Zed. Claude Code es la excepción, y conviene saberlo porque es de las más usadas: lee CLAUDE.md. Se arregla con un fichero de una línea que importe el otro con @AGENTS.md. Compruébalo en tu versión, que esto es de lo poco aquí que puede cambiar en tres meses.

    Si quieres el marco completo alrededor de esto —cómo se escribe la spec de la tarea, no solo el contrato del repo— lo desarrollo en el libro de Spec-Driven Development, en papel o en ebook.

    Empieza por el punto 2. Es el más aburrido de los cuatro y es el que sostiene los otros tres.

    Preguntas frecuentes

    ¿Cómo se revisa código generado por IA sin leer todo el diff?

    Necesitas tres cosas antes de que la pull request llegue a ti: un contrato con cláusulas que una máquina pueda rechazar, límites escritos sobre qué ficheros el agente no toca, y un harness de comandos ejecutables que emita un veredicto binario. Con eso, tu revisión se reduce a tres pasos: mirar el veredicto (treinta segundos), leer el contrato para comprobar que pediste lo correcto (unas cuarenta líneas) y leer el diff solo en las zonas que el contrato no cubre —nombres, encaje con el dominio, si la solución es la adecuada para este proyecto—. En mi repo eso bajó el tiempo medio de revisión de una hora cincuenta a veinte minutos.

    ¿Esto no es simplemente tener buenos tests?

    Los tests son una de las cinco capas, no el mecanismo. Puedes tener una suite excelente y seguir revisando cuatrocientas líneas a mano, porque los tests responden “¿el comportamiento sigue siendo el que era?” y no responden “¿esto hace lo que la spec pidió?” ni “¿el agente se salió de su terreno?”. La pieza que casi nadie tiene es la quinta: criterios de aceptación con un comando detrás. Y si quieres que el test defina el contrato antes de que el agente escriba nada, eso es TDD con IA y encaja encima de esto, no en su lugar.

    Mi repo no tiene tests. ¿Esto me sirve de algo?

    Sí, y probablemente más. Empieza por lo que ya existe aunque no lo llames harness: el build, el type checker y el linter ya emiten veredictos hoy. Escribe el AGENTS.md con esos tres comandos y los límites, y añade tests después, uno por tarea. La alternativa —esperar a tener cobertura para empezar— es como la gente se queda un año sin hacer nada.

    ¿No es más fácil poner todo esto en el prompt?

    Funciona. Casi siempre. El problema es el casi. El prompt vive en una conversación y muere con ella. El fichero vive en el repositorio: se escribe una vez y se aplica a todas las tareas que vengan detrás, incluidas las que lance otra herramienta o cualquiera que entre al repo después de ti.

    ¿Esto no ralentiza al agente?

    Al contrario, aunque el bucle corto sume segundos. Sin verificación el agente entrega rápido y falso, y el coste aparece luego en tu revisión y en los arreglos. Con verificación, el error llega a los veintiocho segundos, con nombre, y lo arregla él. La velocidad que importa no es la de generar código: es la de llegar a algo que se pueda mergear.

    ¿Sirve si mi stack no es TypeScript?

    La estructura es la misma en Python, Go o Java — stack, convenciones, definición de terminado, límites y verificación. Lo que cambian son los comandos concretos, y esos salen de tu proyecto, no de un ejemplo. Copia la forma y rellénala con lo tuyo.


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

  • Evals deterministas para agentes de IA: testea datos, no frases

    Evals deterministas para agentes de IA: testea datos, no frases

    Un developer me enseñó su suite de tests para un agente de soporte. Tenía esta línea:

    expect(result.text).toBe("Tu suscripción ha sido cancelada con éxito.");
    

    En local pasó tres veces. Hizo push. En la cuarta ejecución en CI, el modelo contestó: "Hemos procesado la cancelación de tu suscripción correctamente."

    Pipeline en rojo. La suscripción se canceló. La tool correcta se llamó con el userId correcto. El agente hizo su trabajo y el test falló porque el modelo cambió tres palabras.

    Ese test no medía al agente. Medía la redacción de un modelo probabilístico, justo la parte que no controlas. La salida son los evals deterministas para agentes de IA: en vez de relajar la aserción hasta que ya no garantice nada, cambias lo que el agente devuelve.


    ¿Qué son los evals deterministas para agentes de IA?

    Un eval determinista es una comprobación cuyo resultado no depende de cómo redacte el modelo. El agente no devuelve una frase: devuelve un objeto tipado —un veredicto— y el test asierta de forma exacta sobre sus campos. Un decision que es un enum cerrado, un array de códigos de motivo, un identificador. Datos, no prosa. La misma clase de aserción que harías contra un endpoint REST.

    La diferencia con lo que la mayoría llama "eval" es el punto de aplicación. No estás puntuando una respuesta a posteriori con una rúbrica: estás rediseñando la interfaz del agente para que su decisión sea inspeccionable.

    Conviene marcar la frontera con dos cosas que ya conté por separado. El test harness para agentes de IA es el entorno: las tools falsas, el presupuesto de tokens que corta, el timeout real, la traza reproducible. Es el paso previo y es obligatorio. Este post va de lo otro: qué afirmas dentro de ese entorno.

    Y el function calling tipado con TypeScript valida la ENTRADA: los argumentos que el modelo manda a una tool, que en ai@7 viajan en inputSchema. Aquí hablamos de la SALIDA: el veredicto que emite el agente. Es la otra punta del mismo cable, y casi nadie tipa esa punta.


    Los dos callejones sin salida antes de llegar aquí

    Cuando el test de arriba se pone rojo hay dos salidas habituales, y las dos son peores que el problema: relajar la aserción hasta que deje de garantizar nada, o delegar el juicio en otro modelo.

    El primero es relajar la aserción. Un toContain, una expresión regular, un .toLowerCase().includes(). Queda así:

    expect(result.text.toLowerCase()).toContain("cancel");
    

    Verde. Y ahora ese test pasa también si el agente respondió "No puedo cancelar tu suscripción, contacta con soporte". Acabas de escribir una aserción que da verde cuando el agente hace exactamente lo contrario de lo que le pediste. Un test que no puede fallar en el caso que importa no es un test: es decoración en el pipeline.

    El segundo es montar un LLM-as-a-Judge para todo. Otro modelo lee la respuesta y decide si es correcta. Funciona, pero paga tres precios: es lento (una llamada extra por caso), es caro (y los evals se ejecutan por lotes, así que multiplica), y sobre todo hereda el no-determinismo que intentabas eliminar. Tu suite pasa a depender de que el juez opine igual el martes que el jueves. Y entonces tienes un segundo problema: quién calibra al juez.

    El juez tiene su sitio. Pero es el último recurso, no el primero. Antes de delegar una decisión en otro modelo, pregúntate si esa decisión se puede tipar. La mayoría de las veces se puede.

    Superficie de aserción Determinista Coste Cuándo usarla
    Texto libre con toBe o regex No Cero Nunca sobre la salida del modelo: o revienta con sinónimos o da verde con cualquier cosa
    Objeto tipado (generateObject + Zod) Sí en la aserción Cero extra Siempre que la salida sea una decisión, una clasificación, una extracción o un enrutado
    LLM-as-a-Judge No Alto, una llamada por caso Cuando la calidad es irreductiblemente textual: resúmenes, tono, redacción, código

    El giro: que la decisión sea un dato, no una frase

    Si quieres afirmar sobre la decisión del agente, haz que la decisión sea un campo.

    Con el AI SDK de Vercel eso es generateObject más un schema de Zod. Los ejemplos de este post corren con ai@7, zod@4 y Vitest 4, versiones de septiembre de 2026. El modelo deja de tener libertad de formato: o devuelve algo que valida contra el schema, o falla ruidosamente, que también es información útil.

    Un agente que revisa solicitudes de reembolso:

    // refund-agent.ts
    import { generateObject } from "ai";
    import { anthropic } from "@ai-sdk/anthropic";
    import { z } from "zod";
    import type { RefundTicket } from "./types";
    import { REFUND_POLICY_PROMPT } from "./prompts";
    
    const model = anthropic("claude-haiku-4-5-20251001");
    
    export const RefundVerdictSchema = z.object({
      decision: z.enum(["APPROVED", "REJECTED", "MANUAL_REVIEW"]),
      reasonCodes: z
        .array(
          z.enum([
            "OUTSIDE_RETURN_WINDOW",
            "ITEM_DAMAGED_BY_CUSTOMER",
            "DUPLICATE_REQUEST",
            "OPEN_CHARGEBACK",
            "HIGH_VALUE_ORDER",
            "TRUSTED_CUSTOMER",
          ]),
        )
        .min(1),
      riskSignals: z.object({
        priorRefunds12m: z.number().int().min(0),
        daysSincePurchase: z.number().int().min(0),
      }),
      summary: z.string(),
    });
    
    export type RefundVerdict = z.infer<typeof RefundVerdictSchema>;
    
    export async function reviewRefund(ticket: RefundTicket): Promise<RefundVerdict> {
      const { object } = await generateObject({
        model,
        schema: RefundVerdictSchema,
        temperature: 0,
        instructions: REFUND_POLICY_PROMPT,
        prompt: JSON.stringify(ticket),
      });
    
      return object;
    }
    

    Fíjate en lo que acaba de pasar. reviewRefund ya no devuelve texto: devuelve RefundVerdict. Un tipo. Tu test vuelve a ser un test normal.

    Si ese veredicto es el paso final de un bucle con varias herramientas por medio, el schema es el punto de salida del bucle. Cómo montarlo con estado y reintentos lo desarrollé en el agentic loop en producción con TypeScript.

    Hasta aquí es lo que cuenta todo el mundo. Lo que casi nadie cuenta es que el schema puede estar bien tipado y ser una superficie de test pésima.


    Cómo diseñar el schema del veredicto: 5 reglas

    Esta es la parte que decide si tu suite aguanta seis meses o se convierte en ruido. Cinco reglas.

    1. Enums cerrados, nunca strings libres

    decision: z.string() valida perfectamente y no te sirve de nada. El modelo devolverá "rechazado", luego "Rechazado por política", luego "REJECT". Has movido el problema del texto de la respuesta al texto de un campo.

    // Mal: sigues asertando sobre prosa
    decision: z.string(),
    
    // Bien: el espacio de valores es finito y conocido
    decision: z.enum(["APPROVED", "REJECTED", "MANUAL_REVIEW"]),
    

    Un enum cerrado tiene una propiedad que ningún string tiene: si el modelo quiere decir algo, solo puede decirlo de una manera. Ahí es donde toBe recupera el sentido.

    2. Códigos de motivo, no explicaciones

    Un veredicto que solo dice REJECTED te deja testear el qué, pero no el porqué. Y el porqué es donde viven las regresiones interesantes: el agente sigue rechazando el caso correcto, pero por el motivo equivocado. Eso es un bug que un test binario no ve.

    Por eso reasonCodes es un z.array(z.enum([...])) y no un z.array(z.string()). Con códigos puedes asertar la causa exacta. Con texto libre, vuelves al principio del post.

    Diseñar bien esa lista de códigos es trabajo de verdad: enums demasiado finos y el modelo elige mal entre opciones casi idénticas; demasiado gruesos y no distinguen nada. Empieza por los motivos que ya aparecen escritos en tu política de negocio.

    3. Los scores numéricos son la aserción más frágil que existe

    confidenceScore: z.number() es tentador. Y es una trampa.

    El modelo devuelve 0.82 hoy y 0.79 mañana con la misma entrada. Cualquier test que compare el valor exacto es un test que parpadea. Y cualquier umbral que escribas dentro del prompt —"si la confianza supera 0.8, aprueba"— es lógica de negocio metida en la parte no determinista del sistema.

    Dos reglas:

    • Si el score se queda, asierta rangos o umbrales, nunca el valor: expect(v.confidenceScore).toBeGreaterThan(0.7).
    • Mejor aún: saca el umbral del modelo y ponlo en tu código. Que el agente devuelva señales en bruto (priorRefunds12m, daysSincePurchase) y que la regla la aplique una función TypeScript pura.
    // route-verdict.ts — 100% determinista, testeable sin llamar al modelo
    export function routeVerdict(v: RefundVerdict): "AUTO" | "MANUAL_REVIEW" {
      const { priorRefunds12m, daysSincePurchase } = v.riskSignals;
    
      // El veredicto del agente manda: si pidió revisión humana, no la saltamos
      if (v.decision === "MANUAL_REVIEW") return "MANUAL_REVIEW";
      if (priorRefunds12m >= 3) return "MANUAL_REVIEW";
      if (daysSincePurchase > 30 && v.decision === "APPROVED") return "MANUAL_REVIEW";
    
      return "AUTO";
    }
    

    Cada umbral que mueves del prompt a una función es un test que pasa de probabilístico a exacto.

    4. Separa lo que se asierta de lo que se lee

    El schema puede —y suele— tener campos en texto libre. summary está ahí para que un humano entienda la decisión en el panel de revisión, y hace falta.

    La regla es que ese campo no se asierta jamás. Ni con toContain, ni con regex, ni "solo para comprobar que no viene vacío". Déjalo escrito en un comentario del propio schema, para que el siguiente developer no caiga en la tentación. Un schema tiene dos zonas: la contractual, sobre la que testeas, y la informativa, que solo se lee.

    5. Los campos opcionales fabrican tests frágiles

    En cuanto un campo permite undefined, tu test tiene que decidir qué significa eso. Y normalmente no lo decide: lo esquiva con un ?. y se queda verde por accidente.

    // Ambiguo: ¿no había motivos, o el modelo no los rellenó?
    reasonCodes: z.array(ReasonCode).optional(),
    
    // Explícito: el array siempre viene, y siempre con al menos un motivo
    reasonCodes: z.array(ReasonCode).min(1),
    

    Prefiere valores por defecto, arrays vacíos y uniones discriminadas antes que opcionalidad. Un undefined que atraviesa la suite entera sin que nadie lo asierte es un agujero con forma de test.

    Este tipo de diseño —enums, refinamientos, uniones discriminadas, z.infer para no duplicar tipos— es lo que trabajo paso a paso en el curso de Zod para TypeScript, porque aquí el schema no es validación defensiva: es la superficie de test de todo el sistema.


    El test que resulta

    Con el schema anterior, el eval en Vitest es aburrido. Ese es el objetivo: un test de agente de IA que se lee igual que cualquier otro test de tu suite.

    // refund-agent.eval.test.ts
    import { describe, it, expect } from "vitest";
    import { reviewRefund, type RefundVerdict } from "./refund-agent";
    import { routeVerdict } from "./route-verdict";
    import { lateRequestWithChargeback } from "./fixtures";
    
    describe("refund agent · casos obvios", () => {
      it("rechaza una solicitud fuera de plazo con chargeback abierto", async () => {
        const verdict = await reviewRefund(lateRequestWithChargeback);
    
        expect(verdict.decision).toBe("REJECTED");
        expect(verdict.reasonCodes).toContain("OPEN_CHARGEBACK");
        expect(verdict.reasonCodes).toContain("OUTSIDE_RETURN_WINDOW");
        expect(verdict.reasonCodes).not.toContain("TRUSTED_CUSTOMER");
      });
    });
    
    describe("routeVerdict · sin modelo", () => {
      it("escala a revisión manual con 3 reembolsos previos", () => {
        const verdict: RefundVerdict = {
          decision: "APPROVED",
          reasonCodes: ["TRUSTED_CUSTOMER"],
          riskSignals: { priorRefunds12m: 3, daysSincePurchase: 5 },
          summary: "",
        };
    
        expect(routeVerdict(verdict)).toBe("MANUAL_REVIEW");
      });
    });
    

    Dos detalles que importan.

    El toContain de aquí no es el toContain del callejón sin salida. Sobre un string comprueba subcadenas y da verde con cualquier ruido alrededor; sobre un array de enums comprueba pertenencia exacta a un conjunto cerrado. Misma función, garantías opuestas.

    Y el not.toContain vale tanto como el positivo. Un agente que rechaza el caso correcto pero marca al cliente como fiable está acertando por la razón equivocada, y ese es el fallo que se cuela a producción sin que nadie lo vea.

    Este test no se rompe si el modelo cambia la redacción del summary. Ni si cambia el orden de los motivos. Ni si actualizas a la siguiente versión del modelo y escribe más bonito. Solo se pone rojo cuando el agente decide distinto, que es exactamente lo que querías vigilar. Si quieres afinar el diseño de suites, fixtures y aislamiento de dependencias, ese músculo lo trabajo a fondo en el curso de Testing en Angular con Jest y Testing Library: los ejemplos son de Angular, pero el diseño de suites y fixtures se traslada tal cual.


    Los límites de los evals deterministas en agentes de IA

    Toca ser honesto: el schema hace determinista la aserción, no el modelo.

    temperature: 0 reduce muchísimo la varianza, pero no la elimina. Entre el batching en el servidor, la aritmética en coma flotante y el enrutado interno de los modelos grandes, la misma entrada puede darte una decisión distinta. Menos que antes. No cero.

    La forma de convivir con eso es partir la suite en dos, y esta distinción es la que casi nadie hace.

    Casos obvios. El cliente pide el reembolso de un pedido de hace dos años con un chargeback abierto. Solo hay una respuesta razonable. Estos casos son tests binarios, corren siempre y bloquean el merge. Si uno falla, hay un bug: en el prompt, en el schema o en el modelo que acabas de actualizar.

    Casos de frontera. El pedido tiene 31 días y la política dice 30, pero el cliente lleva cinco años contigo. Aquí ni tú tienes una respuesta única. Estos casos no se testean como binarios: se miden como tasa de acierto. Ejecutas N veces y exiges un umbral de consistencia. Cinco ejecuciones es el mínimo que justifica el coste, no una muestra seria: si el caso importa de verdad, sube a veinte antes de fiarte de la tasa. Por qué N no es un número arbitrario lo desarrollé en evaluaciones automatizadas para agentes.

    // refund-agent.borderline.test.ts
    import { borderlineTicket } from "./fixtures";
    
    async function decisionCounts(runs: number, ticket: RefundTicket) {
      const results = await Promise.all(
        Array.from({ length: runs }, () => reviewRefund(ticket)),
      );
    
      return results.reduce<Record<string, number>>((acc, r) => {
        acc[r.decision] = (acc[r.decision] ?? 0) + 1;
        return acc;
      }, {});
    }
    
    it(
      "mantiene el caso frontera en revisión manual (4 de 5)",
      async () => {
        const counts = await decisionCounts(5, borderlineTicket);
        expect(counts.MANUAL_REVIEW ?? 0).toBeGreaterThanOrEqual(4);
      },
      60_000,
    );
    

    Meter los casos de frontera en la suite que bloquea el merge es la receta perfecta para que el equipo empiece a relanzar pipelines hasta que pasen. Y a partir de ese día los tests dejan de significar nada. Van en un job programado, con su propio umbral y su propia alerta cuando la tasa cae.

    Sí, esta suite cuesta dinero, porque llama al modelo de verdad. Por eso corre por lotes y no en cada push, mientras el test harness con tools falsas sigue corriendo en cada commit.


    Cuándo sí necesitas un LLM-as-a-Judge

    Cuando la calidad de la salida es irreductiblemente textual.

    Si tu agente escribe un resumen, redacta un email a un cliente o genera un módulo entero de código, no hay enum que capture "esto está bien". Ahí el juez —con rúbrica explícita, golden dataset versionado y calibración humana— es la herramienta correcta, y lo desarrollé entero en evals para código generado por IA.

    La regla de reparto es simple: si la decisión se puede tipar, típala; el juez es para lo que sobra después. En la mayoría de agentes de negocio, lo que sobra es mucho menos de lo que parece antes de sentarse a diseñar el schema.


    Por dónde empezar mañana

    Coge un agente. El que más te preocupe.

    Mira qué devuelve hoy. Si devuelve texto, escribe el schema del veredicto: un enum de decisión, un array de códigos de motivo, las señales numéricas en bruto y un summary que no vas a asertar nunca. Cambia la llamada a generateObject. Y mueve al menos un umbral del prompt a una función TypeScript.

    Después escribe cinco casos obvios. Cinco. Con eso ya tienes una red que detecta el día en que cambies de modelo y el agente empiece a aprobar lo que antes rechazaba, que es la regresión que de verdad cuesta dinero.

    Este tipo de decisión de diseño es lo que separa una demo de un producto que aguanta usuarios reales, y es el hilo que sigo en el curso Construye con IA: de la idea al producto con Claude Code. En Dominicode Labs están los schemas y las suites completas de los agentes que corremos en producción, con sus casos de frontera y sus umbrales reales.

    Deja de testear lo que el agente dice. Testea lo que el agente decide.


    Preguntas frecuentes

    ¿Qué es exactamente un eval determinista?

    Es una comprobación automática cuyo resultado no depende de cómo redacte el modelo. Se consigue haciendo que el agente devuelva un objeto tipado en lugar de texto y asertando sobre campos de valores cerrados, como enums o arrays de códigos. La aserción vuelve a ser exacta y repetible, igual que si testearas la respuesta de una API REST.

    ¿Con temperature 0 ya tengo determinismo garantizado?

    No. Reduce mucho la varianza, pero no la elimina, porque hay factores del lado del proveedor que no controlas, como el batching de peticiones o la aritmética en coma flotante. Lo que sí es determinista es tu aserción, y por eso los casos de frontera se miden como tasa de acierto sobre varias ejecuciones en lugar de como un test binario.

    ¿Puedo asertar sobre un campo de confianza numérico?

    Puedes, pero solo por rangos o umbrales, nunca por el valor exacto, porque el mismo caso te dará valores ligeramente distintos entre ejecuciones. La mejor opción es que el modelo devuelva las señales en bruto y que el umbral lo aplique una función de tu código, que sí puedes testear al cien por cien sin llamar al modelo.

    ¿En qué se diferencia esto de un test harness?

    El harness es el entorno de ejecución: las herramientas falsas, el presupuesto de tokens, el timeout y la traza. Responde a si el agente se salió de sus límites. Los evals deterministas son las aserciones que escribes dentro de ese entorno y responden a si el agente decidió lo correcto. Se montan en ese orden: primero el entorno, después las aserciones.

    ¿Estos tests corren en cada push?

    Los que no llaman al modelo, sí: el enrutado, los umbrales y toda la lógica pura alrededor del veredicto. Los que llaman al modelo de verdad cuestan dinero y tardan, así que van en un job programado sobre un conjunto reducido de casos, separando los obvios, que bloquean el merge, de los de frontera, que solo alertan cuando la tasa de acierto cae.


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

  • De API REST a servidor MCP en TypeScript: 1 endpoint no es 1 tool

    De API REST a servidor MCP en TypeScript: 1 endpoint no es 1 tool

    Un equipo con el que trabajé tenía una API REST de facturación con cuarenta y tantos endpoints. Documentada con OpenAPI, en producción desde hacía años, sin drama.

    Quisieron abrirla a un agente. Para pasar de API REST a servidor MCP hicieron lo obvio: generar el servidor desde el spec de OpenAPI. Cuarenta endpoints, cuarenta tools. Media tarde de trabajo. Funcionaba.

    Y el agente era inútil.

    Preguntabas "¿cómo está la cuenta de Marta?" y el modelo elegía listInvoices sin filtro, se tragaba doscientas facturas en el contexto y contestaba una vaguedad. Otras veces llamaba a getCustomer, luego a getCustomerById, luego a searchCustomers — tres tools que hacían casi lo mismo porque el backend llevaba cinco años acumulando variantes.

    El problema no era el modelo. Era que habían traducido en vez de diseñar.

    Cuando pasas de una API REST a un servidor MCP, la conversión mecánica es el error por defecto. Un buen servidor MCP expone menos tools que endpoints tiene la API. Y si no tienes API previa y quieres montar todo desde cero, empieza por construir el agente y su servidor MCP paso a paso — este post asume que ya tienes el backend en producción.

    En una frase: un servidor MCP es un proceso que expone las capacidades de tu backend como tools —funciones con nombre, schema de entrada y descripción— para que un modelo pueda elegirlas y ejecutarlas por sí mismo. Si vienes de cero con el protocolo, el mapa completo está en qué son los servidores MCP. Aquí vamos a lo que casi nadie cuenta: cómo se decide qué parte de tu API merece ser una tool.


    API REST describe recursos, servidor MCP describe capacidades

    La diferencia entre una API REST y un servidor MCP no es el transporte: es quién decide qué llamar, cuándo y con qué información delante.

    API REST Servidor MCP
    Quién elige la llamada Un programador, en tiempo de desarrollo Un modelo, en tiempo de ejecución
    Unidad de diseño El recurso (/customers/{id}) La intención ("cómo está la cuenta de X")
    Para qué sirve la descripción Documentación que se lee una vez Prompt que decide la llamada
    Coste de añadir una más Cercano a cero Contexto en cada petición y más riesgo de elegir mal
    Respuesta ideal El objeto completo, el cliente filtra Lo mínimo para razonar, el servidor recorta
    Errores Código HTTP y cuerpo estructurado Frase accionable con isError: true

    Tu API REST está escrita para un programador: alguien que ya sabe lo que quiere y que entiende por qué /customers/{id}/invoices devuelve algo distinto de /invoices?customer_id={id}. El contrato REST asume una decisión ya tomada.

    MCP es lo contrario. El que lee tu catálogo de tools no sabe nada de tu dominio y tiene que decidir cuál llamar, con qué argumentos y en qué orden — a partir de una frase ambigua de un humano.

    Esto cambia una cosa fundamental: la descripción de la tool no es documentación, es prompt. Es el texto que el modelo tiene delante en el momento de elegir. Si escribes "Obtiene un cliente" dejas la decisión al azar. Si escribes "Úsala cuando necesites el estado de facturación de un cliente a partir de su email; no sirve para crear ni modificar facturas", programas el comportamiento.

    Y hay un coste que en REST no existe: la lista de tools viaja en cada petición. Cuarenta tools con sus schemas ocupan contexto antes de que el agente haya hecho nada. Cada tool que añades encarece todas las llamadas y hace la elección más difícil.


    Qué endpoints de tu API REST se convierten en tool MCP (y cuáles no)

    No, no va una tool por endpoint. El criterio que uso es uno solo: ¿este endpoint responde a una intención completa que un humano formularía?

    Si un usuario puede decir "dime el estado de facturación de Marta" y ese endpoint lo resuelve entero, es candidato. Si es un paso intermedio que solo tiene sentido dentro de una secuencia, no lo es.

    Con eso, esto queda fuera:

    • CRUD granular. PATCH /customers/{id}/phone no es una intención, es un detalle de implementación. Si el agente necesita actualizar datos de contacto, una sola tool update_customer_contact con varios campos opcionales.
    • Endpoints internos. Health checks, webhooks, callbacks de terceros, migraciones. El agente no los necesita y solo compiten por su atención.
    • Los que devuelven payloads enormes. Un GET /events que escupe cinco mil registros no se convierte en tool: se convierte en tool con filtros obligatorios, o no se convierte.
    • Los destructivos sin confirmación. DELETE /customers/{id} no va al servidor MCP tal cual. O lo marcas con destructiveHint y lo dejas detrás de una confirmación del cliente, o directamente no lo expones. Yo arranco siempre en solo lectura y añado escritura una a una.

    Y una regla que ahorra mucho dolor: si dos endpoints se llaman siempre juntos, no son dos tools. Son una.


    La tool de intención: consolida, no traduzcas

    Una tool de intención es una sola tool que resuelve una pregunta completa del usuario agregando por dentro varias llamadas a tu API REST. Ahí está el cambio de mentalidad. La pregunta "cómo está la cuenta de Marta" en tu API REST son tres llamadas:

    GET /customers?email=...        → el cliente
    GET /customers/{id}/invoices    → sus facturas
    GET /customers/{id}/payments    → el estado de pagos
    

    La traducción mecánica te da getCustomer, listInvoices y getPaymentStatus. Tres tools, tres decisiones que el modelo puede equivocar, tres respuestas verbosas en el contexto y una orquestación que el agente tiene que inventarse en cada conversación.

    La versión diseñada te da una: get_customer_billing_summary. Recibe un email, encadena esas llamadas por dentro y devuelve un resumen legible.

    Tres decisiones menos que tomar, dos viajes menos de contexto y una orquestación que ya no depende de que el modelo acierte. Es el mismo backend; cambia dónde vive la lógica de composición.


    Las cuatro piezas que no se traducen solas

    Autenticación. El token de tu API REST no viaja como viajaba. Regla dura: el token nunca es un parámetro de la tool. Si lo pones en el inputSchema, acaba en el contexto del modelo y en los logs del cliente. En local, por stdio, el servidor lo lee de su entorno y el modelo ni se entera. En cuanto lo expones por red la historia se complica bastante — ahí tu servidor pasa a ser un resource server de OAuth 2.1 y toca leer qué se rompe cuando el MCP server sale del portátil.

    Paginación. El agente no debe paginar a mano. Si expones page y per_page, hará cinco llamadas seguidas quemando contexto para reconstruir algo que podías haberle dado resumido.

    Dos opciones honestas: un tope de resultados con un cursor explícito que el modelo pueda pasar de vuelta, o —mejor— los N más relevantes más un "hay 340 resultados, afina el filtro por fecha o estado". Empujar al agente a filtrar gana casi siempre a dejarle paginar.

    Errores. Un 422 con un cuerpo tipo {"errors":{"date":"invalid format"}} es perfecto para un frontend y horrible para un modelo. El agente necesita texto que le diga qué corregir: "El campo date debe ir en formato YYYY-MM-DD. Reformatea el valor y vuelve a llamar." Y va como resultado con isError: true, no como excepción sin capturar: así el modelo lo lee y se autocorrige en el mismo turno en vez de rendirse.

    Tamaño de la respuesta. Tu endpoint devuelve el objeto entero porque a un frontend le sale gratis ignorar campos. Al agente no: cada campo que no usa lo paga en contexto. Recorta en el servidor. De un objeto factura con treinta campos, el agente necesita número, fecha, importe y estado.


    Servidor MCP en TypeScript con el SDK oficial

    Un archivo para hablar con la API que ya tienes, sobre el SDK oficial de TypeScript:

    // src/rest.ts
    const BASE = process.env.BILLING_API_URL!;
    const TOKEN = process.env.BILLING_API_TOKEN!; // del entorno, nunca del modelo
    
    export class RestError extends Error {
      constructor(readonly status: number, readonly body: unknown) {
        super(`REST ${status}`);
      }
    }
    
    export async function rest<T>(path: string): Promise<T> {
      const res = await fetch(`${BASE}${path}`, {
        headers: { Authorization: `Bearer ${TOKEN}`, Accept: 'application/json' }
      });
    
      if (!res.ok) {
        throw new RestError(res.status, await res.json().catch(() => null));
      }
    
      return res.json() as Promise<T>;
    }
    
    export interface Customer {
      id: string;
      name: string;
      email: string;
    }
    
    export interface Invoice {
      number: string;
      issuedAt: string; // YYYY-MM-DD
      amount: number;
      status: 'draft' | 'sent' | 'paid' | 'overdue';
    }
    

    Y el servidor con la tool de intención que agrega dos llamadas REST:

    // src/server.ts
    import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
    import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
    import { z } from 'zod';
    import { rest, RestError, type Customer, type Invoice } from './rest.js';
    
    const server = new McpServer({ name: 'billing', version: '1.0.0' });
    
    server.registerTool(
      'get_customer_billing_summary',
      {
        title: 'Resumen de facturación de un cliente',
        description:
          'Devuelve el estado de facturación de un cliente a partir de su email: ' +
          'datos básicos, facturas recientes e importe vencido. Úsala para responder ' +
          '"cómo está la cuenta de X". No sirve para crear ni modificar facturas.',
        inputSchema: {
          email: z.email().describe('Email del cliente, tal como lo dio el usuario'),
          months: z.number().int().min(1).max(12).default(3)
            .describe('Meses de histórico de facturas a incluir. Por defecto 3.')
        },
        annotations: { readOnlyHint: true }
      },
      async ({ email, months }) => {
        const since = new Date();
        since.setMonth(since.getMonth() - months);
    
        try {
          const [customer] = await rest<Customer[]>(
            `/customers?email=${encodeURIComponent(email)}`
          );
    
          if (!customer) {
            return {
              content: [{
                type: 'text',
                text: `No existe ningún cliente con el email ${email}. ` +
                      `Pide al usuario el email exacto antes de reintentar.`
              }],
              isError: true
            };
          }
    
          const invoices = await rest<Invoice[]>(
            `/customers/${customer.id}/invoices` +
              `?limit=20&since=${since.toISOString().slice(0, 10)}`
          );
    
          const overdue = invoices.filter(i => i.status === 'overdue');
          const owed = overdue.reduce((sum, i) => sum + i.amount, 0);
    
          // Recorte deliberado: solo lo que el agente necesita para razonar
          const lines = invoices
            .slice(0, 10)
            .map(i => `- ${i.number} · ${i.issuedAt} · ${i.amount} € · ${i.status}`);
    
          return {
            content: [{
              type: 'text',
              text: [
                `Cliente: ${customer.name} (${customer.email})`,
                `Facturas últimos ${months} meses: ${invoices.length}`,
                `Vencidas: ${overdue.length} · Importe pendiente: ${owed} €`,
                '',
                ...lines,
                invoices.length > 10 ? `… y ${invoices.length - 10} más.` : ''
              ].join('\n')
            }]
          };
        } catch (error) {
          if (error instanceof RestError && error.status === 422) {
            return {
              content: [{
                type: 'text',
                text: `La API rechazó los parámetros: ${JSON.stringify(error.body)}. ` +
                      `Corrige el valor indicado y vuelve a llamar.`
              }],
              isError: true
            };
          }
          throw error;
        }
      }
    );
    
    await server.connect(new StdioServerTransport());
    

    Fíjate en el .describe() de cada campo: es la única documentación que el modelo recibe de ese argumento. En una API REST el tipo basta porque hay un humano leyendo el spec; aquí el texto es la interfaz. Esa combinación de tipado y semántica es donde Zod deja de ser un validador y pasa a ser parte del diseño — si quieres exprimirlo, lo trabajo a fondo en el curso de Zod para TypeScript. Si sigues en Zod 3, esa línea es z.string().email(); desde Zod 4 la forma recomendada es z.email().

    Un apunte de versiones (septiembre de 2026): el código de arriba corre sobre @modelcontextprotocol/sdk 1.30.0, cuyo inputSchema admite el shape suelto —{ email: z.string() }— y también un z.object({ ... }). La v2 se publica como paquete aparte, @modelcontextprotocol/server 2.0.0, y ahí el shape suelto queda deprecado a favor del z.object() explícito. El monolítico no está deprecado y es el que sigues viendo en la mayoría de servidores, que es por lo que el ejemplo va con él. Los dos implementan la revisión 2026-07-28 de la spec, donde están definidas las annotations y el outputSchema. El criterio de diseño de este post no cambia entre versiones.

    Para probarlo, regístralo en tu cliente y lánzale la pregunta en lenguaje natural. Los scopes y el claude mcp add los tienes desglosados en el tutorial de MCP server con Claude Code.


    Checklist de migración de API REST a servidor MCP

    Antes de dar por buena la conversión de tu API:

    1. Cuenta. ¿Tienes menos tools que endpoints? Si no, no has diseñado, has traducido.
    2. Lee las descripciones en voz alta. Si no explican cuándo usar la tool y cuándo no, reescríbelas.
    3. Busca solapes. Dos tools que un humano confundiría, un modelo también.
    4. Mide el peor payload. Si una respuesta puede reventar el contexto, mete tope y filtros obligatorios.
    5. Convierte los errores. Cada error de tu API tiene que salir como frase accionable con isError: true.
    6. Saca el token del schema. Si aparece en inputSchema, tienes una fuga.
    7. Arranca en solo lectura. Escritura y borrado después, uno a uno y con confirmación.

    Lo que haría hoy

    Abre el OpenAPI de tu API. Marca los endpoints que responden a una frase completa de un usuario. Normalmente son entre cinco y ocho de cuarenta.

    Esos son tus tools. El resto es la fontanería que vive dentro de ellos.

    Y luego escribe las descripciones como si fueran prompts — porque lo son. Esa es la parte que casi nadie hace, y la que separa un servidor MCP que el agente usa bien de uno que solo se ve bonito en el tools/list.

    Este salto de "envolver lo que ya tengo" a "diseñar la superficie que el agente necesita" es el mismo que trabajo en el curso Construye con IA, y si quieres ver servidores MCP reales con sus decisiones y sus errores, los desmenuzamos en Dominicode Labs.


    Preguntas frecuentes

    ¿Cuántas tools debería tener mi servidor MCP?

    No hay número mágico, pero sí una señal: si tienes tantas tools como endpoints, has traducido en vez de diseñar. En APIs de tamaño medio suelo acabar entre cinco y diez tools de intención. La pregunta correcta no es cuántas caben, sino cuántas puedes quitar sin perder capacidad real.

    ¿Puedo generar el servidor MCP automáticamente desde mi OpenAPI?

    Puedes, y es justo lo que produce agentes malos. Un generador hace exactamente la conversión mecánica de 1 endpoint = 1 tool: sin criterio sobre qué endpoints son intenciones completas, sin consolidar llamadas y sin descripciones pensadas para un modelo. Úsalo como inventario de partida si quieres, pero la selección y el redactado de las descripciones son trabajo manual.

    ¿Cómo paso el token de mi API REST al servidor MCP?

    Nunca como parámetro de la tool: ahí acaba en el contexto del modelo y en los logs del cliente. En local, con transporte stdio, el servidor lo lee de una variable de entorno y el modelo ni lo ve. Si lo expones por HTTP, el cliente presenta su propio token al servidor MCP y es tu servidor quien traduce esa identidad a la credencial de la API interna.

    ¿Qué hago con los endpoints de escritura o destructivos?

    Sepáralos desde el arranque. Empieza en solo lectura, marcando esas tools con readOnlyHint, y añade escritura una a una cuando ya sabes cómo se comporta el agente con tu dominio. Lo destructivo lleva destructiveHint y confirmación del cliente; si algo cobra dinero o borra registros, además tiene que ser idempotente.

    ¿La tool debe devolver JSON o texto?

    Texto, salvo razón concreta para lo contrario. El JSON crudo arrastra campos que el agente no usa y paga en contexto. Si además necesitas la forma estructurada, el SDK permite declarar un outputSchema y devolver structuredContent junto al texto.


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

  • Astro 7.3: el candado que bloqueaba tus tests E2E en preview

    Astro 7.3: el candado que bloqueaba tus tests E2E en preview

    Actualizas Astro. Lanzas la suite de Playwright. Un servidor de preview arranca bien y los demás se caen con un error que no habla de tus tests: habla de un lockfile.

    Tú no habías puesto ningún lockfile. Y el commit de esa noche no tocaba nada de testing.

    Ese error tiene una historia detrás, y la historia no va de ti. Va de los agentes de IA.

    Astro 7.3.0 se publicó el 3 de septiembre de 2026 con tres cambios pequeños: el flag --ignore-lock llega a astro preview, el logger de runtime de Astro se pasa a los image services y cache providers custom, y @astrojs/cloudflare 14.3.0 incorpora un helper finalize() para worker entrypoints propios. No hay breaking changes. Ninguno de los tres cambia lo que puedes construir con Astro; los tres cambian cuánto tiempo pierdes cuando algo se rompe.

    Todo lo que cuento aquí sale de las notas oficiales de la release de Astro 7.3, del changelog de astro 7.3.0, del changelog de @astrojs/cloudflare y de la referencia de CLI de Astro.

    Vamos con el primero, que es el que más gente va a notar hoy mismo.


    --ignore-lock llega a astro preview: el arreglo que se nota en el CI

    astro preview levanta un servidor sobre tu build de producción. Es contra ese servidor contra el que corren tus tests E2E, porque probar contra astro dev es probar otra cosa: otro pipeline, otros assets, otro comportamiento.

    Desde Astro 7.2, ese comando escribe un lockfile en .astro/preview.json — el mismo mecanismo que astro dev usa en .astro/dev.json desde Astro 7.0. Un servidor de preview por proyecto y punto: si intentas levantar el segundo, no arranca.

    Astro 7.3 devuelve la escotilla:

    npx astro preview --port 4322 --ignore-lock
    

    El flag se salta la comprobación del lockfile y te deja arrancar tantos servidores de preview como quieras sobre el mismo proyecto, cada uno en su puerto. En Playwright, que es el caso de uso que el propio equipo de Astro menciona en las notas, se traduce en esto:

    // playwright.config.ts
    import { defineConfig } from '@playwright/test';
    
    export default defineConfig({
      webServer: [
        {
          command: 'npx astro preview --port 4321 --ignore-lock',
          url: 'http://localhost:4321',
          reuseExistingServer: !process.env.CI,
        },
        {
          command: 'npx astro preview --port 4322 --ignore-lock',
          url: 'http://localhost:4322',
          reuseExistingServer: !process.env.CI,
        },
      ],
    });
    

    Hay un detalle que conviene leer dos veces. Las notas oficiales avisan de que estas instancias corren de forma independiente, así que están pensadas para servidores rápidos y de usar y tirar, no para los que gestionas con astro preview stop o astro preview status. Traducido: si los arrancas a mano, los matas a mano. Dentro de Playwright da igual, porque el teardown del webServer se encarga.

    Y un aviso que no está en el anuncio de la release pero sí en la referencia del CLI: --ignore-lock no se puede combinar con --background ni con --force, porque los dos dependen del lockfile. Si los juntas, Astro lanza un error.

    Lo que convierte eso en un problema real: Astro activa el modo background por su cuenta cuando detecta que quien ejecuta el comando es un agente de IA. Traducido: si lanzas la suite desde dentro de una sesión de Claude Code o de Cursor, tu --ignore-lock puede petar sin que hayas escrito --background en ninguna parte.

    La salida está documentada y es una variable de entorno:

    ASTRO_PREVIEW_BACKGROUND=0 npx astro preview --port 4322 --ignore-lock
    

    Si lanzas Playwright desde tu terminal, esto no te afecta. Si lo lanza un agente por ti, ponla en el webServer y te ahorras el segundo día de depuración.

    Y hay un segundo escenario, menos obvio y más frecuente de lo que parece: no necesitas dos suites en paralelo para chocar con el candado. Basta con que algo ya lo tenga cogido. Un preview huérfano de la ejecución anterior. O tu agente de IA, que dejó uno levantado en segundo plano mientras trabajaba.

    Esto último no es hipotético, y aquí es donde la release se pone interesante.


    El candado no lo pusieron por ti

    Astro 7 introdujo el lockfile en astro dev con un motivo explícito, y lo dicen ellos con todas las letras en su blog: para que los agentes de IA no levantaran servidores de desarrollo duplicados del mismo proyecto sin darse cuenta. Un problema que hace tres años no existía.

    La secuencia completa es esta:

    Versión Qué pasó
    Astro 7.0 Llega el lockfile a astro dev para frenar servidores duplicados de agentes de IA
    Astro 7.1 Se añade --ignore-lock a astro dev para quien sí quiere varios a propósito
    Astro 7.2 El lockfile se extiende a astro preview, pero sin escotilla
    Astro 7.3 --ignore-lock llega también a astro preview

    Si te interesa el contexto de dónde salió todo esto, lo conté cuando salió la major en el repaso de novedades de Astro v7.

    Aquí está la lección, y va mucho más allá de Astro.

    Tus herramientas están empezando a poner defaults pensados para un agente, no para ti. El lockfile es una decisión sensata cuando quien ejecuta el comando es un modelo que no recuerda si ya levantó el servidor hace diez minutos. Es una decisión molesta cuando quien lo ejecuta eres tú, con un playwright.config.ts delante y muy claro lo que quieres.

    Astro lo ha resuelto bien: default seguro para el agente, flag explícito para el humano. Pero el patrón se va a repetir en todo tu stack, y la próxima vez a lo mejor no te dan el flag el mismo trimestre.

    Por eso insisto tanto en esto cuando trabajo con agentes en el curso de Construye con IA: tienes que saber qué procesos deja vivos tu agente. No porque vaya a romper nada grave, sino porque el día que tu CI falle por un lockfile vas a perder dos horas buscando el bug en tu código.

    Y ya que estamos en testing: el E2E es la capa cara. Casi todo lo que quieres verificar debería estar cubierto más abajo, donde una prueba cuesta milisegundos — lo desarrollé en pruebas unitarias ultrarrápidas con Vitest.


    El logger de runtime llega a los image services y a los cache providers

    Segundo cambio. Menos vistoso, y sin embargo es el que arregla un problema de higiene real.

    Si mantienes un image service custom —el típico wrapper sobre Cloudinary, imgproxy o tu propio CDN— hasta ahora tu única forma de avisar de algo era console.warn(). Con la consecuencia obvia: ese warning se escupía siempre. Ignoraba el nivel de log del proyecto, ignoraba --silent y ensuciaba la salida del build igual en local que en CI.

    En Astro 7.3, los image services reciben el logger de Astro como argumento adicional en transform():

    import type { LocalImageService } from 'astro';
    
    const service: LocalImageService = {
      // ...
      async transform(inputBuffer, transform, imageConfig, logger) {
        logger.warn(`No se pudo optimizar "${transform.src}". Se devuelve sin tocar.`);
        return { data: inputBuffer, format: 'png' };
      },
    };
    

    Y los cache providers lo reciben dentro del contexto que se pasa a onRequest():

    import type { CacheProvider } from 'astro';
    
    const provider: CacheProvider = {
      name: 'my-cache',
      async onRequest({ request, url, logger }, next) {
        logger.warn(`Caché omitida en ${url.pathname}: la respuesta define una cookie.`);
        return next();
      },
      // ...
    };
    

    El servicio Sharp integrado y el provider memoryCache() ya están migrados. Sharp lo usa para avisar de formatos de origen raros o no soportados; memoryCache(), para avisar de respuestas que se salta y de fallos en la revalidación en segundo plano.

    La ganancia no es que ahora "haya logs". Es que tus warnings viajan por el mismo canal que los de Astro y respetan la configuración del proyecto. Si mantienes una integración propia, es un cambio de dos líneas.

    Y sí, esto entra de lleno en la conversación de rendimiento: el image service es una de las piezas que decide el peso real de tus páginas, igual que las Server Islands que analicé en sitios web ultrarrápidos con Astro.


    finalize() en @astrojs/cloudflare: el bug silencioso de las cookies

    Tercer cambio, el más de nicho y el más peligroso de los tres si te toca.

    Va para quien despliega en Cloudflare con un worker entrypoint propio en lugar del que genera el adapter. Ese pipeline manual funciona, pero se salta un paso: aplicar las cookies y los defaults de caché del CDN de Cloudflare a la respuesta que devuelve astro/fetch.

    Un bug de esos que no rompe el build. Simplemente un día descubres que las cookies de sesión no llegan.

    @astrojs/cloudflare 14.3.0, publicada el 3 de septiembre de 2026 —el mismo día que Astro 7.3—, añade finalize() para cerrar ese hueco. Recibe el FetchState y la respuesta del pipeline, y devuelve la respuesta ya con las cookies y la caché aplicadas:

    // src/worker.ts
    import { astro, FetchState } from 'astro/fetch';
    import { cf, finalize } from '@astrojs/cloudflare/fetch';
    
    export default {
      async fetch(request: Request, env: Env, context: ExecutionContext) {
        const state = new FetchState(request);
        const asset = await cf(state, env, context);
        if (asset) return asset;
    
        return finalize(state, await astro(state));
      },
    };
    

    Si usas Hono, no tienes que hacer nada: el middleware @astrojs/cloudflare/hono aplica esas cabeceras por su cuenta.

    El resto de la release te ahorra tiempo. Esta te ahorra un incidente.


    ¿Te toca actualizar a Astro 7.3?

    No hay breaking changes, así que la pregunta no es si puedes, es si ganas algo.

    Tu situación Qué hacer Por qué
    Tienes E2E con Playwright sobre astro preview Actualiza hoy Es la diferencia entre una suite que arranca y una que muere en el webServer
    Trabajas a diario con un agente de IA en el proyecto Actualiza hoy Dejas de pelearte con él por el mismo lockfile
    Mantienes un image service o cache provider custom Actualiza y cambia dos líneas Tus warnings pasan a respetar el nivel de log y --silent
    Despliegas en Cloudflare con worker entrypoint propio Sube @astrojs/cloudflare a 14.3.0 finalize() te quita el bug silencioso de las cookies
    Sitio estático, sin E2E, sin adapter Actualiza sin prisa No hay riesgo, pero tampoco premio

    Si vienes de la rama 6.x, el salto que de verdad cambió cómo se construye con Astro no es este: fue el de Server Islands y Actions en 6.2. Astro 7.3 es mantenimiento fino sobre esa base.


    Lo que yo haría esta semana

    Abre tu playwright.config.ts y mira si el command del webServer llama a astro preview. Si es que sí, añade --ignore-lock y actualiza. Son treinta segundos y te ahorras el día en que el CI falle por un lockfile que tú no pusiste.

    El resto puede esperar al próximo sprint.

    Pero quédate con la idea de fondo, porque vale más que los tres cambios juntos: tu tooling ha empezado a asumir que quien escribe los comandos es un agente. Los defaults se están moviendo hacia ahí. Cuando algo se rompa de forma rara en tu pipeline, esa es la primera hipótesis que deberías poner sobre la mesa, y ya no la última.

    De esto discutimos bastante en Dominicode Labs, porque casi todos los que estamos metiendo agentes en el flujo diario nos hemos comido alguna versión de este mismo problema.


    Preguntas frecuentes

    ¿Qué hace exactamente –ignore-lock en astro preview?

    Se salta la comprobación del lockfile que Astro 7.2 añadió a astro preview, de modo que puedes tener varios servidores de preview del mismo proyecto corriendo a la vez en puertos distintos. El flag ya existía en astro dev desde Astro 7.1; la 7.3 lo lleva también a preview.

    ¿Por qué astro preview –ignore-lock me da error?

    Porque --ignore-lock no se puede combinar con --background ni con --force: las dos opciones dependen del lockfile, así que Astro lanza un error. Y cuando Astro detecta que el comando lo ejecuta un agente de IA, activa el modo background automáticamente, aunque tú no hayas pasado --background. Si te ocurre, desactiva ese automatismo con la variable de entorno ASTRO_PREVIEW_BACKGROUND=0 delante del comando.

    ¿Por qué existe ese lockfile si nadie lo pidió?

    Porque Astro 7 lo introdujo para que los agentes de IA no levantaran servidores duplicados del mismo proyecto sin darse cuenta. Es un default pensado para un ejecutor que no recuerda si ya arrancó el servidor hace diez minutos, y por eso convive mal con flujos donde tú quieres varios servidores a propósito.

    ¿Los servidores lanzados con –ignore-lock se gestionan con astro preview stop?

    No. Las notas oficiales avisan de que esas instancias corren de forma independiente y están pensadas para servidores rápidos y de usar y tirar, no para los que administras con astro preview stop o astro preview status. Si los arrancas a mano, los cierras a mano; dentro de Playwright se encarga el teardown del webServer.

    ¿Tengo que tocar mi image service custom para aprovechar el logger?

    Solo si quieres. El cambio es aditivo: el logger llega como argumento adicional en transform() y dentro del contexto de onRequest() en los cache providers, así que tu código actual sigue funcionando. Cambiar console.warn() por logger.warn() es lo que hace que tus avisos respeten el nivel de log configurado y el flag --silent.

    ¿Necesito llamar a finalize() si uso Hono en Cloudflare?

    No. El middleware @astrojs/cloudflare/hono aplica esas cabeceras automáticamente. finalize() está pensado para quien monta un worker entrypoint propio sobre astro/fetch y necesita aplicar a mano las cookies y los defaults de caché del CDN de Cloudflare.

    ¿En qué versión de @astrojs/cloudflare está finalize()?

    En @astrojs/cloudflare 14.3.0, publicada el 3 de septiembre de 2026 junto a Astro 7.3. El adapter se versiona aparte de astro: subir astro a 7.3 no actualiza el adapter, tienes que subir los dos paquetes.

    ¿Hay breaking changes al actualizar a Astro 7.3?

    No. Es una release menor de mantenimiento sobre la 7.2: un flag nuevo, un logger que se propaga a APIs de extensión y un helper añadido en @astrojs/cloudflare 14.3. La actualización del adapter de Cloudflare va aparte de la de astro, así que revisa que subes las dos si estás en ese escenario.


    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.