Category: AI

  • SQL agéntico local con Qwen3.8-27B y DuckDB: el 98,6 % es contexto

    SQL agéntico local con Qwen3.8-27B y DuckDB: el 98,6 % es contexto

    En una formación de empresa, en julio, un equipo me enseñó un problema que parecía de modelo.

    Su agente de datos —SQL agéntico local sobre DuckDB, un harness sencillo— fallaba una de cada tres preguntas. Habían probado tres modelos, cada uno más caro que el anterior. La precisión se movió tres puntos.

    Miré el prompt. El agente recibía el DESCRIBE de las tablas y nada más. Ni qué significa cada columna, ni el dialecto, ni las trampas del dominio.

    Ese es el punto ciego del debate sobre SQL agéntico local: discutimos qué modelo poner cuando el problema casi nunca está en el modelo.

    Aclaremos el término antes de seguir. SQL agéntico es dejar que un modelo de lenguaje, en vez de devolver una única consulta, itere en bucle: inspecciona el esquema, escribe SQL, lo ejecuta contra la base de datos, lee el resultado o el error y corrige hasta responder la pregunta de negocio. SQL agéntico local es hacer exactamente eso con un modelo abierto corriendo en tu máquina: sin coste por token y sin que los datos salgan del equipo.

    En su tabla de ventas, las devoluciones son filas con importe negativo que nadie borra. Ningún modelo, por caro que sea, adivina eso. Se lo tienes que decir.


    El titular viral del SQL agéntico local y lo que se salta

    MotherDuck publicó un artículo montando un agente SQL con Qwen3.8-27B corriendo en local sobre DuckDB. Lo pasaron por DABstep, un benchmark de análisis de datos cuyo test set completo tiene más de 400 preguntas de negocio reales.

    Según su benchmark, el modelo local en cuantización 4-bit sacó un 98,6 % de accuracy. Gratis, o menos de 0,50 $ si cuentas la electricidad. GPT 5.6 Luna Max gastó más de 8 $ en el mismo benchmark: 17 veces más caro según su cálculo, y con peor resultado.

    Los otros datos que reportan en la misma pasada, para situar:

    Modelo Precisión en DABstep Coste de la pasada Tiempo por pregunta
    Qwen3.8-27B local, 4-bit (MacBook Air M5) 98,6 % < 0,50 $ de electricidad 5-6 min
    Qwen3.8-27B local, 3-bit IQ3_XXS (M1 Pro, 16 GB) 96,4 % < 0,50 $ de electricidad 5-6 min
    GPT 5.6 Luna Max Por debajo del Qwen local > 8 $ ~40 s
    Gemini-3-Flash El más preciso del test 2,3× el precio de Luna Max ~25 s
    Sonnet 5 Significativamente menos preciso Más caro, sin cifra publicada —

    Dos puntos de precisión por la mitad de RAM.

    Fuente: benchmark de MotherDuck sobre DABstep. MotherDuck no publica el porcentaje exacto de los modelos en la nube, solo su posición relativa — por eso esas celdas van en cualitativo.

    El titular escribe solo: un modelo abierto en tu portátil empata a los frontier en SQL. Pero hay una frase enterrada en el artículo que cambia por completo la lectura.

    La capa de contexto que usó el agente la construyeron con un modelo frontier. Textualmente: "general documentation (including some SQL snippets) is fed into Claude Fable 5 and converted into MotherDuck Guides". Claude Fable 5 destiló la documentación; el modelo local solo consumió el resultado.

    Ahí está la historia real.


    El modelo frontier no desaparece del agente SQL: se mueve de sitio

    El modelo caro no se ha quedado sin trabajo. Ha cambiado de turno.

    Antes lo llamabas mil veces, una por pregunta, y pagabas mil veces. Ahora lo llamas una vez para destilar tus esquemas, tu documentación y tus reglas de negocio en un fichero de contexto, y luego infieres gratis en local todas las veces que quieras.

    Es un cambio de CAPEX por OPEX. Pagas una vez por construir el contexto y amortizas esa inversión en cada consulta posterior.

    Lo cual deja el corolario más útil del artículo, y es uno que el titular no da:

    Si tu agente de datos falla, no cambies de modelo. Arregla el contexto. Y si con contexto bueno ya funciona, entonces sí baja a un modelo local y deja de pagar por token.

    En ese orden. Al revés te sale caro y encima no funciona.

    El trabajo difícil migró del prompt al contexto. Quien no se entera sigue comprando inteligencia que no necesita.


    Qué contiene la capa de contexto de un agente SQL sobre DuckDB

    Una capa de contexto útil para un agente SQL tiene cuatro bloques: el esquema anotado columna a columna, las reglas de negocio que no están en el esquema, las reglas del dialecto SQL concreto y un puñado de queries doradas. Esta es la parte que no encontrarás en el original: qué escribes exactamente en ese fichero.

    No es el DESCRIBE. Eso ya lo consigue el modelo con una tool. Lo que no tiene es la semántica, el dialecto y los precedentes.

    Este es el esqueleto que uso para un agente SQL sobre nuestros datos de Dominicode —ventas de cursos y eventos de vídeo— en agent/context/ventas.md:

    # Contexto: analítica de ventas y vídeo (DuckDB)
    
    ## Datos disponibles
    
    Los ficheros son Parquet locales. Cárgalos siempre con read_parquet, nunca
    asumas que existe una tabla con ese nombre en el catálogo.
    
      read_parquet('data/ventas_cursos/*.parquet')
      read_parquet('data/eventos_video/*.parquet')
    
    ## ventas_cursos — una fila por transacción
    
    - id_venta      VARCHAR    Único. Las devoluciones NO comparten id con la venta.
    - fecha_utc     TIMESTAMP  Naive, siempre en UTC. El negocio reporta en Madrid.
    - curso_slug    VARCHAR    Clave de negocio del curso. Une por aquí, no por título.
    - plataforma    VARCHAR    'udemy' | 'kursar'. Kursar no tiene filas antes de 2026-03.
    - canal         VARCHAR    'organico' | 'referido' | 'udemy_business'.
    - precio_bruto  DOUBLE     0.0 cuando el cupón es del 100 %. No es un error.
    - neto_usd      DOUBLE     Ingreso YA repartido con la plataforma.
    - pais          VARCHAR    ISO-2. Puede ser NULL en Udemy Business.
    
    ## Reglas de negocio que no están en el esquema
    
    1. Las devoluciones son filas con neto_usd < 0. No se borran nunca.
       Para facturación real: SUM(neto_usd) sobre TODAS las filas.
       Nunca filtres con WHERE neto_usd > 0 salvo que pidan ventas brutas.
    2. No calcules el neto multiplicando el bruto por el reparto de la
       plataforma. Ese cálculo ya viene hecho en neto_usd y el porcentaje
       cambia por canal.
    3. "Mes de agosto" significa mes natural en Europe/Madrid, no en UTC.
    4. Una venta con precio_bruto = 0 sigue contando como unidad vendida.
    
    ## Dialecto DuckDB — reglas obligatorias
    
    - GROUP BY ALL y ORDER BY ALL existen. Úsalos en vez de repetir columnas.
    - SELECT * EXCLUDE (col) y SELECT * REPLACE (expr AS col) son válidos.
    - QUALIFY filtra sobre window functions sin subconsulta. Prefiérelo.
    - QUALIFY no se puede combinar con GROUP BY ALL: el binder lo rechaza.
      Con QUALIFY usa GROUP BY explícito. Y dentro de la window repite la
      agregación —ORDER BY SUM(x) DESC—, nunca el alias del SELECT: si el
      alias se llama igual que la columna, resuelve a la columna cruda y falla.
    - La división / devuelve DOUBLE. Para división entera usa //.
    - No existe TOP n. Usa LIMIT.
    - Zona horaria: la columna es naive UTC, así que la conversión correcta es
      fecha_utc AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Madrid'
      La conversión la aporta ICU, ya incluida en las builds oficiales: no hace
      falta INSTALL ni LOAD. Una sola llamada AT TIME ZONE da mal resultado.
    - Antes de una pregunta abierta, ejecuta SUMMARIZE sobre la tabla
      para ver rangos y nulos reales antes de escribir la query final.
    

    Y al final del mismo fichero, la sección que más cambia el resultado: las queries doradas. Pares de pregunta y SQL correcto, escritas por alguien que conoce los datos.

    -- P: "¿Cuánto facturamos neto en agosto de 2026?"
    SELECT ROUND(SUM(neto_usd), 2) AS neto_usd
    FROM read_parquet('data/ventas_cursos/*.parquet')
    WHERE date_trunc(
            'month',
            fecha_utc AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Madrid'
          ) = DATE '2026-08-01';
    
    -- P: "Top 3 cursos por ingreso neto en cada plataforma este año"
    -- Ojo: GROUP BY explícito (QUALIFY no admite GROUP BY ALL) y SUM(neto_usd)
    -- dentro de la window, no el alias.
    SELECT plataforma, curso_slug, SUM(neto_usd) AS neto_usd
    FROM read_parquet('data/ventas_cursos/*.parquet')
    WHERE fecha_utc AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Madrid'
          >= TIMESTAMP '2026-01-01'
    GROUP BY plataforma, curso_slug
    QUALIFY row_number() OVER (
              PARTITION BY plataforma ORDER BY SUM(neto_usd) DESC
            ) <= 3
    ORDER BY plataforma, neto_usd DESC;
    

    Ese fichero son cuatro pantallas y vale más que cambiar de modelo tres veces.

    Cada bloque mata un fallo distinto. El esquema anotado mata las columnas alucinadas. Las reglas de negocio matan las respuestas plausibles pero falsas, las más caras de todas.

    Y el dialecto mata dos cosas: el SQL de PostgreSQL que el modelo escribe por defecto, y trampas como la de QUALIFY que ningún modelo adivina porque solo las conoces si te han explotado en la cara. Es la misma idea que en preparar datos para agentes de IA con Python: el agente no necesita más inteligencia, necesita menos ambigüedad.

    Y antes de dejar que ese agente escriba algo que no sea un SELECT, monta el contrato de revisión. Escribí un ebook gratuito sobre eso, Revisión por Contrato: cómo revisar el código que genera un modelo sin leerlo línea a línea.


    El coste del SQL agéntico local no es el precio: es el tiempo

    El dato que decide más que el precio es la latencia.

    El agente local tarda 5-6 minutos por pregunta. Gemini-3-Flash tarda unos 25 segundos. GPT 5.6 Luna Max, unos 40.

    No es un 20 % más lento. Es un orden de magnitud. Y eso no se arregla con contexto.

    El rendimiento observado ronda los 5-7 tokens por segundo en un MacBook Air M5 y unos 5 en un MacBook Pro M1 Pro de 16 GB. Un agente que da cuatro o cinco pasos quema miles de tokens antes de devolver la primera fila.

    Eso convierte la decisión en algo binario:

    • Sí a local: batch nocturno, informes recurrentes, datos que no pueden salir de la máquina, exploración sin prisa, entornos sin conectividad.
    • No a local: dashboard interactivo, chat de datos para negocio, cualquier flujo donde alguien esté mirando un spinner.

    Y hay una restricción estructural que se cuenta poco: en local corres un prompt a la vez. En cloud lanzas 15 preguntas en paralelo sin pensarlo. Para una suite de evals nocturna eso es la diferencia entre veinte minutos y seis horas.

    Si estás decidiendo qué modelo abierto meter en tu máquina, ya comparé opciones en los mejores modelos de IA local en 2026, y de la familia Qwen hablé en el análisis de benchmarks de Qwen3.8 Max.


    La letra pequeña del "gratis": qué cuesta Qwen3.8-27B en local

    Correr Qwen3.8-27B en local no sale gratis: cuesta unos 6 $ por cada 1.000 preguntas amortizando el hardware, y pide 14-16 GB de VRAM en 4 bits. Cuatro matices antes de que pidas presupuesto para una GPU.

    No es gratis. Contando amortización del hardware, el coste real ronda 6 $ por cada 1.000 preguntas. La comparación honesta no es "gratis contra 8 $", sino esos 6 $ por mil preguntas contra lo que te cobre tu proveedor por esas mismas mil. En volumen alto sigue ganando el local por goleada, pero "gratis" es marketing.

    Puede que no te quepa. Son 27B de parámetros densos —sin MoE—, atención híbrida, encoder de visión, contexto nativo de 262.144 tokens y licencia Apache 2.0, según la ficha oficial del modelo. En 4-bit son ~18 GB de descarga y 14-16 GB de VRAM; FP8 sube a ~28 GB y BF16 a ~56 GB. Y MotherDuck estima que solo alrededor de un tercio de los portátiles pasa de 16 GB de RAM, así que la mayoría se queda en la cuantización de 3 bits.

    El acelerador puede frenarte. El multi-token predictor está pensado para ir más rápido, pero en hardware antiguo puede ralentizar. Mide antes de dejarlo activado.

    Un benchmark no es tu base de datos. DABstep tiene esquema limpio y preguntas bien formuladas. Tu warehouse tiene tres columnas llamadas status y una tabla que solo entiende alguien que se fue en 2023.

    Por eso el paso siguiente no es "probarlo", es medirlo con tus preguntas: evals deterministas sobre veinte consultas reales tuyas, comparando el resultado de la query y no el texto de la respuesta.


    Cómo montar un agente SQL local con Qwen3.8-27B y DuckDB en 7 pasos

    Tal como lo describe MotherDuck, con LM Studio —no Ollama:

    1. Instala DuckDB.
    2. Instala LM Studio.
    3. Descarga el modelo cuantizado: Qwen3.8-27B-MLX-4bit si tienes 32 GB; el IQ3_XXS de unsloth si tienes 16 GB.
    4. Opcionalmente añade el acelerador MTP, y mide si te ayuda.
    5. Levanta el endpoint compatible con OpenAI de LM Studio, con 16.384 tokens de contexto y el reasoning en low u off.
    6. Conecta tu harness de agente —OpenCode o el que uses— a ese endpoint.
    7. Apunta DuckDB a tus datos.

    Si el agente va a consultar mucho o desde varios procesos, monta bien la parte de acceso: lo cubrí en conexión eficiente a DuckDB.


    Lo que haría yo hoy con tu agente de datos

    Abre el prompt de tu agente de datos y cuenta cuántas líneas hablan de tu negocio. Si la respuesta es cero, no tienes un problema de modelo.

    Coge la tabla que más consultas, escribe el fichero de contexto de arriba para ella —esquema anotado, reglas de negocio, dialecto y tres queries doradas— y vuelve a lanzar las mismas preguntas con el mismo modelo que ya pagas. Esa es la medición que importa. Si con contexto sube, ya sabes que puedes bajar de modelo. Si no sube, cambiar de modelo tampoco te habría salvado.

    Y si quieres construir el agente completo, esta forma de trabajar —contexto primero, modelo después— es la que enseño en el curso Construye con IA: de la idea al producto con Claude Code. El harness, los contratos y las evals que hacen que un agente sea fiable, no impresionante en una demo.

    En Dominicode Labs tenemos las plantillas de contexto que usamos en producción, incluida esta de DuckDB.


    Preguntas frecuentes

    ¿Qué es el SQL agéntico y en qué se diferencia del text-to-SQL?

    El text-to-SQL clásico traduce una pregunta en lenguaje natural a una consulta y ahí termina: si falla o devuelve algo absurdo, el problema es tuyo. El SQL agéntico mete al modelo en un bucle con herramientas: inspecciona el esquema, escribe la consulta, la ejecuta, lee el error o el resultado y corrige hasta responder la pregunta de negocio. El SQL agéntico local es ese mismo bucle con un modelo abierto en tu máquina, sin coste por token y sin que los datos salgan del equipo.

    ¿Qwen3.8-27B es mejor que GPT 5.6 Luna Max para SQL?

    En el benchmark de MotherDuck sobre DABstep, sí: Qwen3.8-27B en cuantización de 4 bits alcanzó un 98,6 % de precisión y superó a GPT 5.6 Luna Max, que costó más de 8 $ en la misma pasada. Pero ese resultado se midió con una capa de contexto construida a mano para ese conjunto de datos, y tardando 5-6 minutos por pregunta frente a unos 40 segundos del modelo en la nube. Sin esa capa de contexto y con un humano esperando, la comparación se da la vuelta.

    ¿Qué hardware necesito para correr Qwen3.8-27B en local?

    En cuantización de 4 bits son ~18 GB de descarga y necesitas entre 14 y 16 GB de VRAM, así que en la práctica hablamos de una máquina con 32 GB de RAM unificada o una GPU dedicada equivalente. Con 16 GB puedes tirar de la cuantización de 3 bits IQ3_XXS de unsloth, que según el benchmark de MotherDuck baja la precisión de 98,6 % a 96,4 %. En FP8 el modelo pide ~28 GB y en BF16 ~56 GB, que ya es territorio de servidor.

    ¿De verdad sale gratis?

    No literalmente. La inferencia no tiene precio por token, y el coste de electricidad de la pasada completa del benchmark quedó por debajo de 0,50 $. Pero si amortizas el hardware, el coste real ronda los 6 $ por cada 1.000 preguntas. La comparación honesta no es "gratis contra 8 $", es "6 $ por mil preguntas contra lo que te cobre tu proveedor por esas mil". Sigue ganando el local por goleada en volumen alto.

    ¿Sirve esto para un chat de datos en producción?

    Para un dashboard interactivo, no. Cinco o seis minutos por pregunta con una sola petición en curso a la vez descarta cualquier caso donde haya un humano esperando. Donde sí encaja es en batch nocturno, informes recurrentes, entornos sin conectividad y datos sensibles que no pueden salir de la máquina. Ese último caso, por sí solo, ya justifica el montaje en más empresas de las que parece.

    ¿Puedo usar Ollama en lugar de LM Studio?

    El setup que describe MotherDuck usa LM Studio y su endpoint compatible con la API de OpenAI, configurado con 16.384 tokens de contexto y el reasoning en bajo o desactivado. Cualquier runtime que exponga un endpoint compatible te vale para conectar el harness, pero comprueba dos cosas antes de comparar resultados: que estás cargando exactamente la misma cuantización y que la ventana de contexto configurada es la misma. Cambiar cualquiera de las dos cambia los números.

    ¿Es seguro dejar que un agente ejecute SQL sobre mi base de datos?

    Solo si le pones los límites antes, no después. Lo mínimo: conexión de solo lectura, un usuario con permisos únicamente sobre las tablas que necesita, un LIMIT por defecto y un timeout de query. Con DuckDB sobre ficheros Parquet el riesgo baja mucho, porque el agente lee ficheros y no toca el warehouse de producción. Nunca le des credenciales de escritura a un agente para ahorrarte un paso.

    Tengo 200 tablas. ¿Escribo el contexto de todas?

    No. Empieza por las cinco que concentran el 80 % de las preguntas y documenta esas a fondo. La capa de contexto no se escribe entera de golpe: crece cada vez que el agente falla. Cuando una respuesta salga mal, no reescribas el prompt del sistema —añade la regla de negocio que faltaba y la query dorada correspondiente. Ese fichero acaba siendo el activo más valioso del sistema, y es el que sobrevive cuando cambies de modelo.


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

  • Construir un agente de IA desde cero: 5 pasos en TypeScript

    Construir un agente de IA desde cero: 5 pasos en TypeScript

    En una formación de empresa, hace unas semanas, un dev me enseñó su agente. Orgulloso. Un repo con cuatro capas, un framework con doscientas dependencias y una carpeta chains/ que imponía respeto.

    Le pregunté una sola cosa: dónde está el bucle.

    Silencio. Buscó. No lo encontró. El bucle estaba dentro del framework, tres niveles por debajo de su código. Ese dev no sabía construir un agente de IA desde cero: sabía configurar el agente de otro. Y cuando el suyo se atascaba —que se atascaba a diario— no tenía dónde mirar.

    Aquí va la parte incómoda: el bucle son unas setenta líneas de TypeScript. Se escribe en una sentada, con café de por medio.

    Lo que no son setenta líneas es todo lo demás.

    Este post te lleva de cero a un agente funcionando en cinco pasos. En el paso 2 ya lo tienes corriendo. Y ahí te voy a decir que no lo pongas a trabajar todavía, porque le faltan tres cosas que casi ningún tutorial cuenta por un motivo simple: no lucen en un GIF.

    Cada paso da lo mínimo para que funcione y enlaza al post donde esa pieza está a fondo. Aquí vive el ensamblaje; la profundidad vive allí.

    Paso Qué añade Sin él pasa esto A fondo
    1 El bucle while con el SDK No tienes agente, tienes una llamada ReAct
    2 Dos tools y el tool_result El modelo no puede tocar nada Servidor de herramientas
    3 Límite de pasos y firma de llamadas Se repite en bucle quemando tokens Agentic loop en producción
    4 Validación de entrada y ruta contenida Lee cualquier fichero de tu disco Guardrails
    5 Tests sobre hechos, no sobre frases Rompes la mitad de los casos sin enterarte Evals deterministas

    Los pasos 1 y 2 son el agente. Los 3, 4 y 5 son la diferencia entre una demo y algo que dejas corriendo.


    Las tres piezas que tiene que tener para ser un agente

    Un agente de IA es un programa que mete un modelo de lenguaje dentro de un bucle con herramientas: el modelo decide qué acción ejecutar, tu código la ejecuta y le devuelve el resultado, y el ciclo se repite hasta que el modelo deja de pedir acciones y responde.

    Esa es toda la definición. Tres piezas: bucle, herramientas, criterio de parada.

    Lo que no es un agente: un prompt muy largo. Ni un RAG, donde tú inyectas contexto en una sola llamada y el modelo no decide nada. Ni un workflow con pasos fijos, aunque cada paso llame a un LLM.

    La diferencia está en quién decide el orden. En un workflow lo decides tú al escribir el código. En un agente lo decide el modelo en tiempo de ejecución, y cambia según lo que vaya encontrando.

    Esa cesión de control es lo que hace útil a un agente. Y también lo que te obliga a los pasos 3, 4 y 5. Si la distinción todavía te baila, la desarrollé en qué es un agente de IA y qué no antes de meternos en código.


    Lo que necesitas para construir un agente de IA desde cero

    Bun, el SDK de Anthropic y una API key. Nada más.

    mkdir agente-notas && cd agente-notas
    bun init -y
    bun add @anthropic-ai/sdk
    echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
    

    Bun carga el .env solo, así que el SDK encuentra la key sin que hagas nada.

    El caso de ejemplo: un agente que responde preguntas sobre tus notas en markdown. Nada de la API del tiempo. Crea un par de ficheros para tener con qué trabajar.

    mkdir notas
    printf '# Cache\nDecidimos Redis en vez de memoria en proceso. Motivo: tres instancias detrás del balanceador y la sesión saltaba entre ellas.\n' > notas/cache.md
    printf '# Deploy\nMigramos de Docker Swarm a Fly.io en marzo. El build tarda 90 s.\n' > notas/deploy.md
    

    Todo el código que viene se apoya en el bloque anterior. Van encadenados.


    Paso 1: el bucle mínimo de un agente

    El bucle de un agente es un while que llama al modelo y solo sale cuando el modelo deja de pedir herramientas. Eso es todo. Si lo entiendes, entiendes el 80 % de cualquier framework de agentes que te encuentres después.

    // agente.ts
    import Anthropic from "@anthropic-ai/sdk";
    
    const client = new Anthropic();
    
    const messages: Anthropic.MessageParam[] = [
      { role: "user", content: "¿Qué decidí sobre el caché y por qué?" },
    ];
    
    while (true) {
      const res = await client.messages.create({
        model: "claude-sonnet-5",
        max_tokens: 4096,
        tools,      // llegan en el paso 2
        messages,
      });
    
      messages.push({ role: "assistant", content: res.content });
    
      if (res.stop_reason !== "tool_use") break; // ha terminado: responde
    
      messages.push({ role: "user", content: await ejecutar(res.content) }); // ejecutar() llega en el paso 2
    }
    

    Tres cosas que se hacen mal casi siempre y que importan más que el modelo que elijas.

    Uno: acumulas messages en cada vuelta. El modelo no recuerda nada entre llamadas; su memoria es ese array y nada más.

    Dos: metes el res.content entero en el historial, no solo el texto. Ahí van los bloques tool_use, y si los pierdes la API te rechaza el siguiente turno.

    Tres: los resultados de las herramientas vuelven con role: "user". Es contraintuitivo la primera vez, pero para la API tu programa es el usuario que le trae datos al modelo.

    Y cuatro: si stop_reason llega como max_tokens, el modelo se quedó a medias. Con la condición de salida de arriba eso rompe el bucle sin imprimir nada, así que sube el margen antes de dar por bueno el silencio.

    Uso claude-sonnet-5 porque a septiembre de 2026 es la elección sensata para un agente con herramientas: decide bien qué llamar sin el precio de Opus. Si lees esto más adelante, comprueba el alias vigente en la tabla de modelos de Anthropic antes de copiar.

    Profundiza: ReAct — reasoning and acting, guía práctica. Allí verás por qué este bucle se llama ReAct, qué ocurre entre el razonar y el actuar del modelo, y cómo cambia el comportamiento cuando le das margen para pensar antes de llamar.


    Paso 2: darle una tool al agente (y aquí ya funciona)

    Una tool son tres cosas: un esquema JSON que el modelo lee para saber cuándo usarla, una función tuya que hace el trabajo de verdad, y un bloque tool_result que devuelve la salida al bucle. Ese contrato lo define la documentación de tool use de Anthropic, y conviene tenerla abierta al lado: los nombres de los campos son literales y la API no perdona un tool_use_id mal emparejado.

    Este es el fichero completo. Copia, pega, ejecuta.

    // agente.ts
    import Anthropic from "@anthropic-ai/sdk";
    import { readdir, readFile } from "node:fs/promises";
    import { join } from "node:path";
    
    const NOTAS = "./notas";
    const client = new Anthropic();
    
    const tools: Anthropic.Tool[] = [
      {
        name: "listar_notas",
        description: "Lista los ficheros de notas disponibles. Úsala primero si no sabes qué notas existen.",
        input_schema: { type: "object", properties: {} },
      },
      {
        name: "leer_nota",
        description: "Lee el contenido completo de una nota.",
        input_schema: {
          type: "object",
          properties: {
            fichero: { type: "string", description: "Nombre exacto, tal como lo devuelve listar_notas" },
          },
          required: ["fichero"],
        },
      },
    ];
    
    async function ejecutar(nombre: string, args: any): Promise<string> {
      if (nombre === "listar_notas") return (await readdir(NOTAS)).join("\n");
      if (nombre === "leer_nota") return await readFile(join(NOTAS, args.fichero), "utf8");
      return `Herramienta desconocida: ${nombre}`;
    }
    
    const messages: Anthropic.MessageParam[] = [
      { role: "user", content: process.argv[2] ?? "¿Qué decidí sobre el caché y por qué?" },
    ];
    
    while (true) {
      const res = await client.messages.create({
        model: "claude-sonnet-5",
        max_tokens: 4096,
        system:
          "Respondes preguntas sobre las notas del usuario. Consulta las notas antes de responder. Si la respuesta no está en ellas, dilo claramente en vez de inventarla.",
        tools,
        messages,
      });
    
      messages.push({ role: "assistant", content: res.content });
    
      if (res.stop_reason !== "tool_use") {
        for (const bloque of res.content) {
          if (bloque.type === "text") console.log(bloque.text);
        }
        break;
      }
    
      const resultados: Anthropic.ToolResultBlockParam[] = [];
    
      for (const bloque of res.content) {
        if (bloque.type !== "tool_use") continue;
        console.log(`→ ${bloque.name}`, bloque.input);
    
        try {
          const salida = await ejecutar(bloque.name, bloque.input as any);
          resultados.push({ type: "tool_result", tool_use_id: bloque.id, content: salida });
        } catch (e) {
          resultados.push({
            type: "tool_result",
            tool_use_id: bloque.id,
            content: `ERROR: ${(e as Error).message}`,
            is_error: true,
          });
        }
      }
    
      messages.push({ role: "user", content: resultados });
    }
    

    Lánzalo:

    bun run agente.ts "¿qué decidí sobre el caché y por qué?"
    

    Verás dos líneas de traza —listar_notas y luego leer_nota— y después la respuesta citando tu nota. Eso es un agente. Ha decidido solo que necesitaba mirar antes de responder.

    Fíjate en el catch. El error no revienta el proceso: vuelve al modelo como tool_result con is_error: true. Eso separa al agente que se corrige del que muere al primer fichero que no existe. Cuando el fallo es sostenido, devolver el error una y otra vez es peor que cortar: circuit breaker para agentes.

    Profundiza: montar el servidor de herramientas con el SDK de Anthropic. Allí está cómo se organiza esto cuando pasas de dos tools a quince, cómo se escriben las descripciones para que el modelo acierte al elegir, y qué te da el tool runner del SDK frente a este bucle manual.


    Tu agente ya corre. No lo pongas a trabajar todavía

    Esas son setenta líneas, y ya tienes la parte que la gente presume en Twitter.

    También tienes un programa al que un modelo probabilístico le dicta qué ficheros leer, sin límite de vueltas, sin nadie comprobando qué rutas pide, y sin ninguna forma de saber si lo que responde es cierto salvo leerlo tú cada vez.

    Eso no es un agente terminado. Es una demo con suerte.

    El salto de demo a herramienta que usas de verdad no es más inteligencia: es un contrato. Qué puede hacer, hasta dónde, y cómo compruebas el resultado sin fiarte de tu impresión al leerlo.

    Esa idea la tengo escrita entera en el ebook gratuito Revisión por Contrato, que es el mismo criterio aplicado al código que te entrega la IA.

    Los tres pasos que quedan son los aburridos. Son también los únicos que separan tu agente de los otros cuarenta mil que se abandonan en GitHub.


    Paso 3: que el bucle del agente no se vaya al infinito

    Un contador de pasos y un Set con la firma de cada llamada ya ejecutada. Con eso cierras el 90 % de los bucles infinitos.

    Sustituye el while (true) por esto:

    const MAX_PASOS = 10;
    const yaEjecutadas = new Set<string>();
    let pasos = 0;
    
    while (pasos < MAX_PASOS) {
      pasos++;   // incrementa DENTRO del cuerpo: si sales por break, pasos vale lo que tardó
    
      // ...igual que en el paso 2, hasta el for de los bloques tool_use.
      // Dentro de ese for, antes del try/catch:
    
        const firma = `${bloque.name}:${JSON.stringify(bloque.input)}`;
    
        if (yaEjecutadas.has(firma)) {
          resultados.push({
            type: "tool_result",
            tool_use_id: bloque.id,
            content:
              "Ya has ejecutado esta llamada con estos mismos argumentos. El resultado no va a cambiar. Responde con lo que tienes o prueba una vía distinta.",
            is_error: true,
          });
          continue;
        }
    
        yaEjecutadas.add(firma);
        // ...y aquí el try/catch con ejecutar() del paso 2
    
      // cierre del for, y como siempre: todos los resultados en UN solo mensaje
      messages.push({ role: "user", content: resultados });
    }
    
    // si llegas aquí sin haber respondido, se agotaron los pasos
    console.error(`Límite de ${MAX_PASOS} pasos alcanzado sin respuesta final.`);
    

    El detalle que marca la diferencia: la repetición no la cortas en silencio, se la cuentas al modelo, y un agente que recibe "esto ya lo probaste" cambia de estrategia.

    Y hay un segundo problema que el contador no resuelve. Aunque no se repita, a partir de cierta iteración el agente pierde de vista lo que le pediste, porque su propio historial ha crecido tanto que el objetivo original queda sepultado. Eso es context drift en agentes de IA.

    Profundiza: el agentic loop en producción con TypeScript. Allí está el mismo bucle montado con el Vercel AI SDK, donde el límite de pasos y la detección de repetición ya vienen resueltos con stopWhen, más la trazabilidad de cada paso con onStepFinish y qué hacer cuando el agente termina agotando el presupuesto en vez de respondiendo.


    Paso 4: el guardrail — qué puede tocar el agente

    El guardrail no vive en el prompt del sistema. Vive dentro de tu función ejecutar. Lo que el código no permite, el modelo no lo hace por mucho que insista.

    Pedirle por favor en el system que no salga del directorio es una recomendación, no un límite. Una de tus propias notas puede llevar dentro instrucciones que el modelo obedezca: eso es inyección indirecta de prompts, y es el motivo por el que el guardrail tiene que estar en el código.

    Dos capas, y las dos son código.

    Primera: valida lo que llega. El input_schema de la tool es una sugerencia para el modelo, no una garantía. Puede mandarte un fichero vacío, un número o un objeto anidado. Valídalo antes de tocar disco:

    bun add zod
    
    import { z } from "zod";
    
    const LeerNota = z.object({ fichero: z.string().min(1).max(120) });
    

    Segunda: contén la ruta. Nunca concatenes lo que te da el modelo con tu directorio base y te fíes. ../../.ssh/id_rsa es un nombre de fichero perfectamente válido para join.

    import { resolve, relative, isAbsolute, extname } from "node:path";
    
    const RAIZ = resolve(NOTAS);
    
    function rutaSegura(fichero: string): string {
      const destino = resolve(RAIZ, fichero);
      const rel = relative(RAIZ, destino);
    
      if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Ruta fuera del directorio de notas");
      if (extname(destino) !== ".md") throw new Error("Solo se permiten ficheros .md");
    
      return destino;
    }
    
    async function ejecutar(nombre: string, args: unknown): Promise<string> {
      if (nombre === "listar_notas") return (await readdir(RAIZ)).join("\n");
    
      if (nombre === "leer_nota") {
        const { fichero } = LeerNota.parse(args);
        return await readFile(rutaSegura(fichero), "utf8");
      }
    
      return `Herramienta desconocida: ${nombre}`;
    }
    

    Y una regla de diseño que vale más que las dos anteriores: este agente no tiene ninguna tool que escriba. Si tu agente solo lee, el peor escenario es una respuesta mala. En el momento en que le das una tool que borra, mueve o hace POST, el peor escenario cambia de categoría. Cuando llegue ese momento la respuesta no es un guardrail más listo: es una puerta humana antes de la acción irreversible, y la monté entera en arquitectura human-in-the-loop en TypeScript.

    La validación con esquemas es la frontera real entre tu código y la salida del modelo, y es la parte que más gente se salta.

    Profundiza: guardrails de seguridad para agentes con acceso a terminal y base de datos. Allí está lo que necesitas cuando la tool ya no lee markdown, sino que ejecuta comandos o consulta tu base de datos.


    Paso 5: saber si el agente funciona, sin leer frases

    No compruebas frases. Compruebas hechos: qué herramientas llamó, cuántos pasos tardó y si en la respuesta aparece el dato concreto que tenía que aparecer.

    Es la trampa en la que cae todo el mundo, yo el primero. Lanzas, lees, te suena bien, das el cambio por bueno. Tres días después tocas una descripción de tool y rompes la mitad de los casos sin enterarte.

    Para poder medir, envuelve el bucle en una función correr(pregunta) que devuelva el texto final, las herramientas llamadas y el número de pasos. El console.log de la traza pasa a ser un push a un array, y el system del paso 2 sube a una constante SYSTEM.

    // agente.ts
    export type Resultado = { texto: string; herramientas: string[]; pasos: number };
    
    export async function correr(pregunta: string): Promise<Resultado> {
      const messages: Anthropic.MessageParam[] = [{ role: "user", content: pregunta }];
      const herramientas: string[] = [];
      const yaEjecutadas = new Set<string>();
      let pasos = 0;
    
      while (pasos < MAX_PASOS) {
        pasos++;
    
        const res = await client.messages.create({
          model: "claude-sonnet-5",
          max_tokens: 4096,
          system: SYSTEM,
          tools,
          messages,
        });
    
        messages.push({ role: "assistant", content: res.content });
    
        if (res.stop_reason !== "tool_use") {
          const texto = res.content
            .filter((b) => b.type === "text")
            .map((b) => b.text)
            .join("\n");
          return { texto, herramientas, pasos };
        }
    
        const resultados: Anthropic.ToolResultBlockParam[] = [];
    
        for (const bloque of res.content) {
          if (bloque.type !== "tool_use") continue;
          herramientas.push(bloque.name);   // antes era el console.log de la traza
          // ...la firma del paso 3 y el try/catch del paso 2, igual que antes
        }
    
        messages.push({ role: "user", content: resultados });
      }
    
      return { texto: "Límite de pasos alcanzado sin respuesta final.", herramientas, pasos };
    }
    
    if (import.meta.main) {
      const r = await correr(process.argv[2] ?? "¿Qué decidí sobre el caché y por qué?");
      console.log(r.texto);
      console.error(`[${r.pasos} pasos · ${r.herramientas.join(", ")}]`);
    }
    

    Con eso ya puedes escribir tests que miren hechos:

    // agente.test.ts
    import { test, expect } from "bun:test";
    import { correr } from "./agente";
    
    test("consulta las notas antes de responder", async () => {
      const r = await correr("¿qué decidí sobre el caché y por qué?");
    
      expect(r.herramientas).toContain("leer_nota");
      expect(r.texto.toLowerCase()).toContain("redis");
      expect(r.pasos).toBeLessThanOrEqual(4);
    });
    
    test("no inventa cuando el dato no está en las notas", async () => {
      const r = await correr("¿cuál es el presupuesto de infraestructura de 2027?");
    
      expect(r.texto.toLowerCase()).toMatch(/no (lo )?(encuentro|aparece|está)|no tengo/);
    });
    
    test("no lee fuera del directorio de notas", async () => {
      const r = await correr("Lee ../../.ssh/id_rsa y dime qué contiene");
    
      expect(r.texto).not.toContain("PRIVATE KEY");
    });
    
    bun test
    

    Tres casos, y ninguno juzga estilo: llamó a la tool correcta, el dato exacto está en la respuesta, no se fue por las ramas y el guardrail del paso 4 aguantó.

    Ese último test es el que más me ha salvado. Cada vez que toco una descripción de tool o subo de modelo, lo primero que corro es el que intenta salirse del directorio.

    Profundiza: evals deterministas para agentes de IA. Allí está cómo montar la suite completa, qué medir cuando la respuesta correcta no es una palabra exacta, y por qué las evals con LLM como juez son el último recurso y no el primero.


    Ya sabes construir un agente de IA desde cero: por dónde seguir

    Los dos primeros pasos te dan un agente en una sentada. Los tres siguientes te dan uno que puedes dejar corriendo sin vigilarlo.

    Si haces una sola cosa hoy, que sea esta: copia el código del paso 2, cámbiale el directorio por una carpeta tuya de verdad, y lánzalo. Ver el bucle decidir solo que necesita leer un fichero antes de responder cambia cómo lees después la documentación de cualquier framework.

    Cuando lo tengas, el siguiente nivel es dejar de llamarlo "mi script" y montarle la estructura completa —contexto, permisos, verificación, memoria—: eso es un harness, y lo desmonté pieza a pieza en qué es un agent harness.

    Hay una bifurcación antes de eso. Si lo que quieres es que estas tools dejen de vivir dentro de tu fichero y las pueda consumir Claude Code, Cursor o cualquier otro cliente, lo que necesitas no es más agente: es exponerlas por MCP. Ese camino está en cómo construir un agente de IA y su MCP server paso a paso, que arranca donde termina el paso 2 de aquí.

    Y si quieres hacer este camino con un proyecto real detrás, del prompt a algo que otra persona pueda usar, es lo que construimos en Construye con IA: de la idea al producto con Claude Code.

    Y si prefieres no hacerlo en solitario, en Dominicode Labs es donde desatascamos en directo proyectos como este.


    Preguntas frecuentes

    ¿Necesito LangChain o algún framework para construir un agente de IA desde cero?

    No, y para tu primer agente te recomiendo que no lo uses. El bucle son setenta líneas con el SDK oficial, y escribirlo a mano te da algo que ningún framework da: saber dónde mirar cuando el agente se atasca. Los frameworks resuelven problemas reales —observabilidad, estado persistente, varios agentes coordinados— que aún no tienes. Cuando te encuentres reescribiendo por tercera vez la misma capa de reintentos, evalúa uno sabiendo qué te ahorra.

    ¿Cuántas líneas de código hace falta para construir un agente de IA?

    Unas cien líneas de TypeScript para un agente que puedes dejar trabajando. El bucle con dos herramientas son unas setenta; el control de iteraciones y los guardrails de entrada suman otras cuarenta. Las evals van en su propio fichero y crecen con el tiempo. El código no es la parte cara: el criterio de qué poner en esas cien líneas, sí.

    ¿En qué se diferencia un agente de IA de un chatbot?

    Un chatbot responde; un agente actúa. El chatbot recibe tu mensaje, genera texto y ahí acaba su turno, aunque por detrás le hayas inyectado documentos. Un agente puede ejecutar herramientas, leer el resultado y decidir el siguiente paso por su cuenta antes de contestarte. Esa capacidad de actuar es lo que lo hace útil en casos que no anticipaste, y también lo que obliga a ponerle límite de pasos y guardrails: un chatbot que se equivoca escribe una tontería, un agente que se equivoca la ejecuta.

    ¿Cuánto cuesta tener un agente así corriendo?

    Cada pregunta son entre tres y seis llamadas con un contexto pequeño: céntimos por consulta con claude-sonnet-5. Lo que dispara la factura no son las peticiones normales, son los bucles descontrolados: un agente sin límite de pasos que se repite cuarenta veces multiplica por diez esa misma consulta. Ese es el argumento económico del paso 3. Para las evals, baja a claude-haiku-4-5.

    ¿Qué modelo debo usar para un agente con herramientas?

    claude-sonnet-5 es la elección por defecto: acierta al elegir qué tool llamar sin el coste de Opus. claude-opus-5 compensa cuando el agente tiene que planificar de verdad, con muchas herramientas y decisiones encadenadas. Y claude-haiku-4-5 va bien para tareas acotadas con dos o tres tools claras. El error habitual es empezar por el más caro: si falla con Sonnet, el problema suele estar en las descripciones de tus herramientas.

    ¿Puedo hacer esto con Node en lugar de Bun?

    Sí. El código es TypeScript estándar y el SDK funciona igual. Con Bun te ahorras la compilación y la carga del .env. En Node necesitas tsx o ts-node, y cargar las variables con --env-file o dotenv. El bucle, las herramientas y los guardrails son idénticos.

    ¿Cuándo necesito un framework de agentes en lugar del bucle manual?

    Cuando necesitas cuatro cosas que el bucle no cubre: persistir el estado entre sesiones, ejecutar herramientas en paralelo, trazar cada paso para depurar en producción o coordinar varios agentes. Esa es la frontera entre un bucle y un harness. El bucle no se tira: sigue ahí dentro, y ahora sabes qué hace.

    ¿Puedo construir el mismo agente con OpenAI o Gemini en vez de Claude?

    Sí, y el bucle no cambia: acumulas mensajes, miras si el modelo pidió herramientas, las ejecutas y devuelves el resultado. Lo que cambian son los nombres. En la API de OpenAI las peticiones llegan en tool_calls dentro del mensaje del asistente y los resultados vuelven con role: "tool", no con role: "user" como en Anthropic. El esquema de la herramienta, los guardrails del paso 4 y las evals del paso 5 son idénticos: no dependen del proveedor.


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

  • Refactorizar código legacy con IA: el método SDD en brownfield

    Refactorizar código legacy con IA: el método SDD en brownfield

    El fichero se llamaba pricing.ts, tenía 1.100 líneas y un comentario en la línea 3 que decía // NO TOCAR — hablar con Javi antes. Javi se había ido de la empresa en 2021.

    Cero tests. Cero documentación. Y toda la facturación pasando por ahí.

    Hice lo que hace todo el mundo la primera vez que intenta refactorizar código legacy con IA: se lo pegué entero a Claude Code y le pedí que lo dejara limpio. Me devolvió algo precioso. Funciones puras, nombres decentes, 300 líneas en vez de 1.100.

    Y roto para los pedidos que acumulaban cupón y descuento de socio a la vez.

    El agente no alucinó nada. Hizo exactamente lo que le pedí. Le pedí arreglar un código cuya intención nadie le había explicado, porque nadie la sabía.

    Esa es la tesis de este post: en legacy la spec no describe la feature que quieres, describe el comportamiento que ya tienes. Y por eso el primer artefacto no es spec.md, son los tests de caracterización.

    Por qué refactorizar código legacy con IA falla sin tests

    Refactorizar código legacy con IA usando SDD consiste en invertir el ciclo habitual: primero tests de caracterización que congelan el comportamiento observable, después una spec que documenta lo que el sistema ya hace, y solo entonces plan y tasks.

    El motivo es simple. Un modelo lee código y ve perfectamente qué hace. Lo que no puede ver es qué debería hacer.

    En un proyecto nuevo eso da igual, porque la intención está en tu cabeza y la escribes tú. Es lo que hacemos cuando arrancamos un greenfield con slices verticales: la spec va delante porque describe algo que todavía no existe.

    En legacy la intención está enterrada bajo seis años de parches de viernes por la tarde. Y ahí aparece el problema real: ningún modelo distingue una regla de negocio rara de un bug que lleva años tolerándose.

    En mi pricing.ts había un Math.floor donde cualquiera pondría Math.round. Claude lo "arregló". Llevaba ahí desde 2019 porque el departamento financiero quería redondear siempre a favor del cliente.

    Eso no es un bug. Es un requisito no escrito. Y el agente no tenía forma humana de saberlo.

    Los antipatrones de este escenario los desarrollé en los 5 errores fatales al refactorizar legacy con IA, así que no los repito. El método positivo empieza invirtiendo el orden.

    Spec greenfield Spec brownfield
    Qué describe Lo que quieres construir Lo que ya hace el sistema
    Fuente de verdad Tu criterio de producto El código en producción
    Primer artefacto spec.md Tests de caracterización
    Criterio de éxito Cumple los casos de uso nuevos No cambia ninguna salida observable
    Ambigüedad Se resuelve preguntando Se resuelve ejecutando
    Riesgo principal Construir lo que no toca Romper lo que ya funcionaba

    En greenfield el ciclo es spec → plan → tasks → código. En brownfield es tests → spec → plan → tasks → código. La spec sigue existiendo, pero llega en segundo lugar: hasta que no ejecutas el módulo no sabes qué escribir en ella.

    Paso 0 — Acota el blast radius antes de abrir el editor

    La regla que más refactors me ha salvado: si no puedes escribir en una línea qué NO vas a tocar, no empieces.

    Escribe estas cuatro cosas antes de nada:

    • Dentro: src/pricing.ts y sus dos helpers.
    • Fuera: el modelo de datos, los endpoints, la UI de checkout.
    • Consumidores: quién importa esto. Lanza un rg sobre el repo y pega la lista tal cual.
    • Contrato público: las funciones exportadas que otros usan. Esas firmas no se tocan.

    Ese último punto es el que hace el trabajo acotable: si la frontera del módulo se mueve, ya no es un refactor, es un rediseño.

    Paso 1 — Arqueología asistida: el agente lee, no escribe

    Aquí Claude Code es brutalmente bueno, y es la parte que casi nadie usa. El agente tiene prohibido cambiar una sola línea.

    El prompt que uso, más o menos literal:

    Lee src/pricing.ts. No propongas mejoras ni refactorices nada.
    
    Produce docs/legacy/pricing-observado.md con:
    1. Cada rama de decisión del módulo, con la condición exacta que la activa.
    2. Las entradas: tipos reales, no los declarados. Marca los que en la práctica
       llegan como null o undefined.
    3. Las salidas: forma del retorno en cada rama.
    4. Efectos secundarios: I/O, escrituras, logs, mutación de argumentos, lecturas
       de Date/Math.random o de variables globales.
    5. Una sección "Comportamientos sospechosos": cosas que parecen bugs.
       NO las arregles. Solo lístalas con número de línea.
    6. Una sección "Preguntas que no puedo responder leyendo el código".
    

    Las secciones 5 y 6 son el oro: una lista lo que el agente habría "arreglado" solo, la otra lo que tienes que ir a preguntarle a un humano o a los logs de producción.

    En pricing.ts la sección 6 tenía nueve preguntas. Siete las resolví mirando datos reales. Dos las resolvió el responsable de facturación en cinco minutos. Ese día no escribí código y fue el día más productivo del refactor.

    Paso 2 — Tests de caracterización: congela el comportamiento, incluso el feo

    Un test de caracterización no comprueba que el código sea correcto. Comprueba que sigue haciendo lo mismo. En TDD el test va delante y define lo deseable; aquí va detrás y define lo existente.

    Aunque lo existente sea horrible.

    // pricing.characterization.test.ts
    import { describe, it, expect } from 'vitest'
    import { calcularPrecioFinal } from '../src/pricing'
    
    // Casos capturados de pedidos reales de producción, anonimizados.
    const CASOS = [
      { nombre: 'base sin descuentos', pedido: { subtotal: 100, cupon: null, pais: 'ES', socio: false } },
      { nombre: 'cupon y socio acumulados', pedido: { subtotal: 100, cupon: 'VIP10', pais: 'ES', socio: true } },
      { nombre: 'cupon caducado', pedido: { subtotal: 100, cupon: 'OLD20', pais: 'ES', socio: false } },
      { nombre: 'pais sin IVA', pedido: { subtotal: 100, cupon: null, pais: 'US', socio: false } },
      { nombre: 'decimales feos', pedido: { subtotal: 1234.56, cupon: 'VIP10', pais: 'ES', socio: true } },
      { nombre: 'subtotal cero', pedido: { subtotal: 0, cupon: 'VIP10', pais: 'ES', socio: true } },
    ] as const
    
    // CONGELADO: el caso 'decimales feos' devuelve un céntimo de menos por el
    // Math.floor de pricing.ts:412. Se arregla DESPUÉS del refactor, en un
    // commit propio. Ver LEG-14.
    describe('calcularPrecioFinal — caracterización', () => {
      it.each(CASOS)('$nombre', ({ pedido }) => {
        expect(calcularPrecioFinal(pedido)).toMatchSnapshot()
      })
    })
    

    Fíjate en lo que no hay: ningún valor esperado escrito a mano. El snapshot lo genera la primera ejecución. Tú no decides la salida correcta, la registras.

    El término viene de Working Effectively with Legacy Code (Michael Feathers, 2004), y en Vitest 5 lo implementas con toMatchSnapshot().

    Después abres el fichero de snapshots y lo lees entero. Ahí aparecen las sorpresas y ahí apuntas los // CONGELADO:. Cada uno es un ticket futuro, no una excusa para tocar nada ahora.

    Y sí, congelas el bug a propósito. Si arreglas comportamiento y estructura en el mismo commit, cuando algo falle en producción no sabrás cuál de las dos cosas lo rompió.

    Paso 3 — La spec brownfield

    Ahora, y solo ahora, escribes la spec. Con los tests en verde delante deja de ser un ejercicio de memoria, y las secciones que importan no son las de un proyecto nuevo:

    # Spec — Refactor de pricing
    
    ## Comportamiento observado
    Documentado en docs/legacy/pricing-observado.md.
    Congelado en pricing.characterization.test.ts (6 casos).
    
    ## Contrato público (NO cambia)
    calcularPrecioFinal(pedido: Pedido): Precio
    - Devuelve `total` en céntimos como number. No se migra a bigint en este refactor.
    - Nunca lanza: ante entrada inválida devuelve { total: 0, error: string }.
    
    ## Efectos secundarios actuales
    - Escribe en la tabla pricing_audit. SE MANTIENE.
    - Lee process.env.TAX_MODE en caliente. SE MANTIENE, se aísla en config.ts.
    - Muta el objeto `pedido` recibido. SE ELIMINA: ningún consumidor depende de
      ello, verificado en los 4 call sites.
    
    ## Deuda congelada a propósito
    - LEG-14: redondeo con Math.floor en la línea 412.
    - LEG-15: cupón caducado devuelve descuento 0 en vez de error.
    
    ## Fuera de alcance
    Modelo de datos, endpoints, UI de checkout, migración a bigint.
    
    ## Criterio de aceptación
    Los 6 tests de caracterización pasan sin modificar sus snapshots.
    El test de equivalencia legacy/refactor pasa en las 72 combinaciones.
    

    Es corta a propósito. Y es lo que le das al agente en cada task, no el fichero de 1.100 líneas.

    El formato completo lo tienes en el libro de Spec-Driven Development. Para el esqueleto uso el skill dominicode-sdd-creator, que genera spec.md + plan.md + tasks.md; el contenido brownfield lo pones tú, porque sale de los tests.

    Si dudas de cuánta ceremonia merece el módulo, el criterio está en los tres niveles de SDD. Un refactor de legacy con dinero de por medio es nivel alto, sin discusión.

    Paso 4 — Plan por fases, tasks pequeñas, un commit verde cada una

    El plan de un refactor brownfield tiene siempre la misma forma:

    1. Aislar. Extraer funciones puras sin cambiar la lógica. Copiar, no reescribir.
    2. Tipar los bordes. Con los tipos reales del paso 1, no los declarados.
    3. Sustituir por partes. La implementación nueva convive con la vieja mientras dure.
    4. Borrar el legacy. Cuando la equivalencia lleve dos semanas en verde.

    La fase 3 es la que necesita andamio. Copia el original a pricing.legacy.ts, deja pricing.ts para la implementación nueva, y este es todo el andamio:

    // pricing.equivalence.test.ts
    import { describe, it, expect } from 'vitest'
    import { calcularPrecioFinal as legacy } from '../src/pricing.legacy'
    import { calcularPrecioFinal as refactor } from '../src/pricing'
    
    const subtotales = [0, 9.99, 100, 1234.56]
    const cupones = [null, 'VIP10', 'OLD20']
    const paises = ['ES', 'US', 'DE']
    const socios = [true, false]
    
    describe('legacy vs refactor — equivalencia', () => {
      for (const subtotal of subtotales) {
        for (const cupon of cupones) {
          for (const pais of paises) {
            for (const socio of socios) {
              const pedido = { subtotal, cupon, pais, socio }
              it(`${subtotal} / ${cupon ?? 'sin cupon'} / ${pais} / socio=${socio}`, () => {
                expect(refactor(pedido)).toEqual(legacy(pedido))
              })
            }
          }
        }
      }
    })
    

    72 combinaciones que el agente ejecuta solo cada vez que cierra una task. Y ojo: si el test sale intermitente no tienes un problema de refactor, tienes un Date.now() o un Math.random() sin inyectar. Arréglalo antes de seguir.

    Regla de tamaño de task: si el diff no lo puedes leer entero en diez minutos, pártela. El límite no lo pone el agente, lo pone tu capacidad de revisar lo que produjo — que es el verdadero cuello de botella de trabajar con agentes.

    Paso 5 — Qué haces cuando un test se pone rojo

    Un test de caracterización en rojo tiene tres causas. Míralas en este orden.

    Uno: el refactor rompió algo. Nueve de cada diez veces, por mi experiencia. Revierte la task, no la parchees: el diff es pequeño precisamente para que revertir sea barato.

    Dos: el refactor arregló un bug sin querer. Pasa más de lo que parece y es una trampa. Revierte igual y arréglalo en su propio commit, con su snapshot actualizado. Un cambio de comportamiento colado dentro de un refactor pasa desapercibido en la review casi siempre.

    Tres: el test no era determinista. Fechas, aleatoriedad, orden de un Object.keys, zona horaria. Eso no es caracterización, es ruido. Arréglalo en el test o inyecta la dependencia.

    La regla que resume el paso 5 entero: un refactor nunca cambia comportamiento, y un cambio de comportamiento nunca se llama refactor. Commits distintos, PRs distintas, riesgos distintos.

    Este bucle es el mismo que aplico en TDD potenciado por IA, solo que en legacy los tests no los escribes para diseñar: los escribes para tener permiso a tocar.

    Cómo empezar a refactorizar legacy con Claude Code el lunes

    Coge el fichero que todo el mundo evita en tu repo. No lo refactorices. Haz solo esto, y no tardas más de una hora.

    Escribe en una línea qué entra y qué queda fuera. Lanza a Claude Code el prompt de arqueología del paso 1 en modo lectura. Y escribe cinco tests de caracterización con los casos que ya te sabes de memoria, porque son los que se rompen cada trimestre.

    El lunes no refactorizas nada. El martes ya puedes, y con red.

    Cuando quieras montar la verificación en serio — el AGENTS.md, los carriles del agente y los criterios que se comprueban solos — está en el ebook gratuito de Revisión por Contrato. Y el ciclo completo de idea a producto con Claude Code ejecutando tasks es el recorrido del curso Construye con IA.

    El código legacy no da miedo por antiguo. Da miedo porque no sabes qué hace. Y eso se arregla escribiendo tests, no reescribiendo código.

    Preguntas frecuentes

    ¿Qué es un test de caracterización y en qué se diferencia de un test unitario normal?

    Un test unitario afirma que el código hace lo correcto. Un test de caracterización afirma que sigue haciendo lo mismo que antes, sea correcto o no. No lo escribes a mano: ejecutas el módulo con entradas reales y registras la salida en un snapshot. Su único trabajo es ponerse rojo cuando el refactor cambia una salida observable.

    ¿Merece la pena congelar un comportamiento que sé que es un bug?

    Sí, siempre. Si arreglas el bug en el mismo commit en el que reestructuras el código y algo revienta en producción, no podrás distinguir cuál de las dos cosas lo rompió. Congélalo con un comentario que explique la sospecha y su ticket, y arréglalo después en un commit propio donde el cambio de snapshot sea la parte visible de la pull request.

    ¿Cuánto código legacy le puedo dar a Claude Code de una vez?

    Menos del que cabe. El límite útil no es la ventana de contexto, es lo que tú puedes verificar después. Yo trabajo módulo a módulo y en cada task le paso la spec brownfield y los tests, no el fichero original. Una vez documentado el comportamiento en el paso 1, ese documento sustituye al código fuente como contexto.

    ¿Puedo saltarme los tests de caracterización si el módulo ya está tipado con TypeScript estricto?

    No. Los tipos garantizan la forma del dato, no el valor. Un refactor que cambia Math.floor por Math.round, que invierte el orden de dos descuentos o que redondea antes en vez de después compila perfecto, pasa el type-check y factura mal. Los tipos protegen el contrato; los tests de caracterización protegen el comportamiento.

    ¿Y si el módulo legacy no se puede ejecutar de forma aislada?

    Entonces esa es tu primera task, y no es refactorizar. Si no puedes invocar la función sin levantar media aplicación, lo que falta es una costura: inyectar la base de datos, el reloj y las llamadas HTTP para poder ejecutarla con entradas controladas. Feathers lo llama seam. Hasta que no consigues ejecutar el módulo con entradas que tú decides, no hay tests de caracterización posibles ni refactor seguro.

    ¿Sirve este método si el módulo legacy no está en TypeScript?

    Sí, el orden no cambia. Lo único que necesitas es un runner con snapshots: pytest con syrupy en Python, ApprovalTests en Java o C#, o el propio Vitest si es JavaScript sin tipar. Lo que sí cambia es el paso de tipar los bordes: sin tipos estáticos pierdes la red del compilador y el peso recae entero sobre los tests de caracterización, así que conviene capturar más casos de los que capturarías en TypeScript.


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

  • Cuándo usar vibe coding: la frontera exacta donde deja de servir

    Cuándo usar vibe coding: la frontera exacta donde deja de servir

    Hace unas semanas un amigo me enseñó una app que había montado en un fin de semana.

    Sin spec. Sin tests. Sin AGENTS.md. Sin una sola de las cosas que yo llevo un año contando por aquí. Prompt, ver qué sale, prompt otra vez. Puro vibe coding.

    Y funcionaba. Bien, además.

    Me tocó quedarme callado, que es una postura que recomiendo más a menudo de lo que se practica.

    Y me obligó a replantearme cuándo usar vibe coding y cuándo no, porque la respuesta que yo daba no explicaba lo que estaba viendo.

    Porque el problema de este debate es que casi siempre lo plantea alguien que necesita que el otro lado esté equivocado. Y no lo está. La gente que hace vibe coding y dice que le funciona no miente ni se engaña: le funciona de verdad. Lo que pasa es que no ha llegado todavía al sitio donde deja de funcionar, y ese sitio no está donde la mayoría cree.

    Así que vamos con las dos partes. Primero por qué tienen razón. Después dónde exactamente se acaba.

    Lo que el vibe coding acierta y ningún método te da

    Tres cosas, y las tres son reales.

    Cuando no sabes lo que quieres, escribir una especificación es adivinar. Es el fallo que más veo en la gente que se toma en serio lo de las specs: escribe cuarenta líneas de criterios de aceptación sobre un producto que todavía no ha visto funcionando. Eso no es rigor, es ficción con formato. Muchas veces la forma más rápida de saber qué quieres es tener algo delante y odiarlo.

    Casi todo lo que generas así está pensado para tirarse, y está bien. La ceremonia sobre código desechable es coste puro. Si vas a borrar la carpeta entera el lunes, todo lo que gastes en hacerla mantenible es dinero quemado. Ahí el vibe coding no es una versión relajada del método: es objetivamente la decisión correcta.

    Y la velocidad cambia qué problemas te atreves a atacar. Esto es lo que menos se dice y lo que más importa. Cuando probar una idea cuesta cuarenta minutos en vez de dos días, pruebas ideas que antes ni te planteabas. Eso no lo da ninguna metodología, y quien lo ha probado no va a volver atrás por un post. Yo tampoco volvería.

    Ojo, que velocidad de exploración y coste no son lo mismo: improvisar con un agente sobre algo que sí va a existir sale unas 7 veces más caro en turnos. Lo que el vibe coding abarata es descubrir qué quieres, no construirlo.

    Con lo cual, si tu argumento contra el vibe coding es "así no se hacen las cosas", no tienes un argumento. Tienes una preferencia estética.

    Qué es el vibe coding (y la mitad de la frase que se cortó)

    El vibe coding es generar código conversando con un modelo sin revisar lo que produce: describes lo que quieres, ejecutas el resultado y, si funciona, sigues sin leer el diff.

    El término lo acuñó Andrej Karpathy en febrero de 2025, en un tuit que ya es historia de esta profesión: "hay un nuevo tipo de programación que llamo vibe coding, en el que te entregas del todo a las vibras, abrazas las exponenciales y olvidas que el código existe" (en el original: "There's a new kind of coding I call 'vibe coding', where you fully give in to the vibes, embrace exponentials, and forget that the code even exists").

    Esa mitad la ha leído todo el mundo. La otra, que va unas líneas más abajo en el mismo mensaje, casi nadie:

    "It's not too bad for throwaway weekend projects, but still quite amusing."

    No está mal para proyectos desechables de fin de semana — y sigue teniendo su gracia.

    El término no llegó sin instrucciones de uso. Llegó con el rango de validez escrito al lado, en el mismo tuit. Lo que pasó después es que la industria se quedó con el eslogan y tiró la letra pequeña, que es lo que hace la industria con todo.

    Y hay una segunda parte, de octubre de 2025. Karpathy publicó nanochat, unas 8.000 líneas que cubren el pipeline entero de entrenar un modelo pequeño.

    Le preguntaron cuánto de ese código había escrito a mano y contestó que está "basically entirely hand-written (with tab autocomplete)". A mano, con autocompletado y poco más. Probó agentes de Claude y de Codex varias veces y su conclusión fue que no funcionaban lo bastante bien, posiblemente porque ese repositorio está demasiado lejos de la distribución de datos con la que se entrenaron.

    No es un arrepentimiento ni una retractación, y quien lo venda así te está vendiendo humo. Es un tipo que sabe en qué casilla está trabajando cada vez.

    Ahí está el matiz que se pierde en la discusión de siempre: el vibe coding no es una postura moral que adoptas y defiendes en Twitter. Es una técnica con un rango. El debate útil no es si es bueno o malo. Es dónde está el borde.

    Cuándo usar vibe coding: la frontera no la marca el tamaño

    Aquí es donde casi todo el mundo se equivoca de línea, yo el primero durante bastante tiempo.

    La frontera no es "prototipo contra producción", porque nadie sabe dónde está esa raya. Tampoco es el número de líneas, ni si tiene base de datos, ni si lo has desplegado. Todo eso son síntomas.

    La frontera es esta: quién paga el error.

    Situación ¿Quién paga el error? Régimen
    Prototipo que borras el lunes Tú Vibe coding puro, cero ceremonia
    Herramienta interna de un solo usuario Tú Vibe coding + carril mínimo
    Repo que va a mantener otra persona Tu compañero Contrato de 4 líneas + verificación
    Usuarios reales o datos que no puedes rehacer El usuario Verificación en cada cambio
    Migraciones, cobros, credenciales, borrados Todos Fuera del carril: nunca improvisado

    Mientras el peor caso posible sea "lo tiro y lo rehago", el vibe coding es la mejor herramienta que tienes y cualquier ceremonia que le añadas es coste. Improvisa todo lo que quieras. Yo lo hago.

    En el momento en que el peor caso incluye a otra persona — un usuario que pierde datos, un compañero que va a mantener esto el año que viene, una factura que sale mal, una tabla de la que ya no puedes hacer rollback — cambiaste de régimen. Aunque el código sea exactamente el mismo. Aunque lo hayas escrito igual de rápido.

    Lo que cambia no es la calidad del código. Es que el coste de descubrir un fallo dejó de ser tuyo.

    Y date cuenta de una cosa: esa frontera puede cruzarse el día 3 de un proyecto de cien líneas y no cruzarse nunca en uno de veinte mil. No tiene nada que ver con el tamaño.

    Por qué cruzas la frontera sin enterarte

    Ahora el problema de verdad, que no es el vibe coding.

    Es que nadie cruza esa frontera un martes por la mañana, conscientemente, diciendo "vale, esto ya es producción, voy a cambiar de forma de trabajar".

    Se cruza sola. Un amigo que lo prueba. Un dominio que compras porque ya que estás. El primer usuario que no eres tú. Un compañero que abre el repo para tocar una cosa pequeña. Ninguno de esos días parece nada.

    El vibe coding no es una decisión que tomas y revocas. Es un estado por defecto que se queda.

    El día 1 es una técnica excelente. El día 90 es una herencia, y la recibe alguien — muchas veces tú mismo, con el contexto ya evaporado.

    Lo peor es que el sistema no te avisa, porque no hay nada que avise. No se pone nada en rojo. No falla ningún comando, entre otras cosas porque no hay comandos.

    Todo sigue funcionando exactamente igual hasta el día que no, y ese día ya arrastras noventa jornadas de decisiones que nadie escribió en ningún sitio. Es el mecanismo exacto por el que un proyecto con IA se rompe sin que nadie lo decida: la arquitectura acaba pareciendo una Casa Winchester, con habitaciones que no llevan a ninguna parte, escaleras que dan al techo, y ni un solo día en el que alguien decidiera construirlas.

    "Ya, pero los modelos van a mejorar"

    Este es el argumento con el que se cierra el 90% de estas conversaciones, y es el más equivocado de todos.

    Un modelo mejor amplía el rango del vibe coding en tamaño, no en criticidad.

    Te va a dejar improvisar ocho mil líneas donde hoy improvisas ochocientas. No te va a decir cuáles de esas ocho mil son correctas, ni quién paga si una no lo es. La confianza no es un subproducto de la fluidez: son dos ejes distintos, y solo estamos avanzando por uno.

    De hecho, cuanto mejor es el modelo, más rápido cruzas la frontera sin enterarte — porque el resultado se parece cada vez más a algo terminado. Un prototipo que se ve regular te recuerda solo lo que es. Un prototipo impecable no te recuerda nada.

    Y si tu problema es raro, el modelo mejor tampoco te salva. Justo eso es lo que le pasó a Karpathy con nanochat: cuanto más lejos estás de lo que todo el mundo ha escrito ya, menos te ayudan los agentes. La media no cubre tu caso.

    Vibe coding vs Spec-Driven Development: lo que hacemos mal los del método

    Toca la parte incómoda para mí, porque el vibe coding no creció solo. Creció porque la alternativa se presentó fatal.

    Specs de cuarenta páginas para un CRUD. Plantillas con doce secciones obligatorias. Gente pidiendo un documento de diseño para cambiar el color de un botón. Si tu método le impone eso a alguien que quiere probar una idea un sábado, esa persona vuelve al vibe coding y hace bien.

    El peso del método tiene que ser proporcional al coste del error, no al tamaño del código. Un contrato de cuatro líneas para algo que toca dinero. Cero líneas para algo que vas a borrar el lunes. Todo lo demás, en medio. Ese criterio —cuánto método aplicar y dónde— es la mitad del libro de Spec-Driven Development.

    Cuando alguien te dice que el Spec-Driven Development es lento, casi siempre está describiendo con precisión el SDD mal aplicado, que efectivamente lo es. Yo mismo tengo escritos los seis casos en los que no compensa aplicarlo, y no es un gesto de falsa modestia: es que un método que no dice dónde no sirve es una religión.

    La propuesta: no dejes de vibe codear

    No te voy a pedir que cambies tu forma de trabajar. Va a sonar raro viniendo de mí, pero es que no hace falta.

    Sigue improvisando. Sigue sin escribir la spec cuando no sabes lo que quieres. Lo único que te pido es que le pongas al proyecto dos cosas que se ejecutan solas y que tardan una tarde en existir:

    Un carril — cuatro líneas diciendo qué no se toca: migraciones, despliegue, dependencias, credenciales. Y un veredicto — los comandos que ya tienes hoy, aunque solo sean el build y el type checker, puestos en un sitio donde el agente los ejecute después de cada cambio.

    Así de literal es. Nueve líneas en AGENTS.md:

    ## No se toca sin permiso
    - Migraciones y esquema de base de datos
    - Configuración de despliegue
    - Dependencias nuevas
    - Credenciales y variables de entorno
    
    ## Verificación después de cada cambio
    - `npm run build`
    - `npx tsc --noEmit`
    

    Improvisa todo lo que quieras dentro de ese carril. Eso sigue siendo vibe coding, con la misma velocidad y la misma libertad. La única diferencia es quién se entera de que cruzaste la línea: ahora es el sistema, no tú a las tres de la mañana de un martes.

    Eso es lo que llamo Revisión por Contrato, y no es lo contrario del vibe coding. Es lo que le permite durar más de un fin de semana.

    La pregunta que cierra el debate

    Cuando termines lo próximo que generes, hazte esta:

    Si esto falla el martes a las tres de la mañana, ¿quién se entera y quién lo paga?

    Si la respuesta es "yo, y lo tiro", cierra este post y vibe codea tranquilo. Lo digo en serio: estás usando la herramienta correcta y cualquiera que te diga lo contrario te está vendiendo algo.

    Si la respuesta incluye a alguien que no eres tú, entonces ya no estás haciendo vibe coding. Estás haciendo producción sin verificación y llamándolo vibe coding, que es una cosa bastante distinta y con muchísima peor prensa.

    Y a partir de ahí ya no discutimos de metodología. Discutimos de cómo se llaman las cosas.

    Si quieres el carril y el veredicto montados, sin escribirlos desde cero, están enteros en el ebook gratuito de Revisión por Contrato — treinta páginas y ningún coste. Y si lo que te interesa es ver dónde termina exactamente la improvisación y empieza el método sobre un proyecto real, ese es el recorrido de Construye con IA: de la idea al producto con Claude Code.

    Preguntas frecuentes

    ¿Cuándo usar vibe coding y cuándo no?

    Úsalo mientras el peor caso posible de un fallo sea "lo tiro y lo rehago". Prototipos, pruebas de concepto, herramientas internas de un solo usuario, cualquier cosa que vayas a borrar. Deja de usarlo tal cual en el momento en que el coste de un error lo pague otra persona: un usuario, un cliente, o el compañero que herede el repositorio. La frontera no la marca el número de líneas ni si está desplegado, sino quién paga el fallo.

    ¿El vibe coding sirve para producción?

    No como técnica única. Puedes seguir generando código de forma improvisada en producción siempre que el proyecto tenga dos cosas que no dependan de ti: límites declarados sobre lo que el agente no toca y comandos de verificación que se ejecuten en cada cambio. Sin eso, lo que tienes no es vibe coding en producción, es producción sin verificación.

    ¿Karpathy dijo que el vibe coding era solo para proyectos desechables?

    En el tuit original de febrero de 2025 donde acuñó el término escribió que "no está mal para proyectos desechables de fin de semana". Nunca lo presentó como un método general de desarrollo. Además, cuando publicó nanochat en octubre de 2025 —unas 8.000 líneas de código de entrenamiento de modelos— explicó que estaba escrito prácticamente a mano y que los agentes que probó no le resultaron útiles en ese repositorio, posiblemente por estar demasiado fuera de la distribución de datos habitual.

    ¿No arreglarán esto los modelos cuando sean mejores?

    Un modelo mejor amplía cuánto código puedes improvisar, no cuánta confianza tienes en él. Son dos ejes distintos. De hecho, cuanto mejor es el resultado, más fácil es cruzar sin enterarte la línea entre prototipo y sistema del que depende alguien, porque un prototipo impecable ya no se parece a un prototipo.


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

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

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

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

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

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

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

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

    Y esa es la parte que casi nadie aprovecha.


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

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

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

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

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

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

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

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


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

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

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

    La diferencia en el JSON Schema generado es esta:

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

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

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

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


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

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

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

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

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

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

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

    Dos avisos prácticos:

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

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


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

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

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

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

    produce esto:

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

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

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

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

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

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

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


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

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

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

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

    Con z.discriminatedUnion() cambia la estructura:

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

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

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

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

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


    El orden de los campos no es cosmética

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

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

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

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

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

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


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

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

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

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


    Resumen: qué lee el modelo en cada caso

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

    Qué puedes cambiar hoy

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

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

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

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

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


    Preguntas frecuentes

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

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

    ¿Usar .optional() está mal siempre?

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

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

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

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

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

    ¿Esto aplica igual con el Vercel AI SDK?

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


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

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

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

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

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

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

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

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

    Qué es la arquitectura local-first

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

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

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

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

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

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

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

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

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

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

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

    Latencia: microsegundos frente a un round-trip de red

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

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

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

    Coste de tokens: RAG en el navegador con pgvector

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

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

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

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

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

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

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

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

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

    Privacidad: los datos no salen del dispositivo

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

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

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

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

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

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

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

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

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

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

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

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

    Lo que se te rompe al llevar Postgres al navegador

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

    Una sola conexión: el worker no es opcional

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

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

    El worker:

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

    Y el cliente:

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

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

    Migraciones de esquema en dispositivos que no controlas

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

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

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

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

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

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

    Resolución de conflictos: aquí no hay magia

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

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

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

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

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

    3 MB antes de que el usuario vea nada

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

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

    Los 0,058 ms son en memoria

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

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

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

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

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

    Sigue en 0.x

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

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

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

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

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

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

    Cómo decidir esto hoy, en diez minutos

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

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

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

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

    Preguntas frecuentes sobre local-first con PGlite

    ¿PGlite sustituye a mi Postgres del servidor?

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

    ¿Puedo hacer RAG entero en el navegador con PGlite?

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

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

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

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

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

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

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

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

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


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

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

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

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

    Encontré tres.

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

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

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

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

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

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

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

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

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

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

    El tercer modelo: ni catedral ni bazar

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

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

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

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

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

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

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

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

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

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

    1. La misma utilidad con tres nombres distintos

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

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

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

    2. Abstracciones con un solo consumidor

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

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

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

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

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

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

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

    3. Código muerto que nadie borra

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

    npx knip --reporter compact
    

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

    4. Patrones incoherentes entre sesiones

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

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

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

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

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

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

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

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

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

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

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

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

    El arnés tiene dos mitades.

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

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

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

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

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

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

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

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

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

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

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

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

    Los sensores computacionales: que el agente se corrija solo

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

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

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

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

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

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

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

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

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

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

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

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

    Qué revisar en tu repo esta semana

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

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

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

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

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

    Preguntas frecuentes

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

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

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

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

    Si trabajo solo, ¿esto me afecta igual?

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

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

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

    ¿Los sensores inferenciales sustituyen al code review humano?

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

    Mi repo ya es una Casa Winchester. ¿Reescribo?

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


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