Tag: Agentes IA

  • Qué es Jev de TypeSafe AI: primitivas, calibración y límites

    Qué es Jev de TypeSafe AI: primitivas, calibración y límites

    Tienes un clasificador de tickets en producción. Entra un ticket, lo mandas a un modelo de cientos de miles de millones de parámetros y esperas casi dos segundos a que razone en voz alta para acabar escupiendo una palabra: billing.

    Pagas la entrada, pagas la salida —cinco veces más cara— y te llevas una etiqueta. Y no sabes si el modelo estaba seguro o echando una moneda al aire: el JSON sale válido en los dos casos.

    Ese es el agujero que quiere tapar Jev de TypeSafe AI, que salió el 15 de septiembre de 2026. Hace cuatro días.

    Lo firma Diogo Almeida, co-autor de InstructGPT y co-inventor del RLHF en OpenAI, con 40 millones de dólares liderados por DCVC. No es un wrapper: es una arquitectura nueva que renuncia a generar texto a propósito.

    En corto: Jev es el primer modelo de TypeSafe AI y no genera texto. Recibe un estado no estructurado y devuelve decisiones tipadas —binarias, elecciones o puntuaciones— con probabilidades calibradas, en unos 250 ms medidos de extremo a extremo —unos 100 ms de inferencia más el viaje de red— y a $0,042 por millón de tokens de entrada con la salida gratis. Sirve para clasificar, enrutar, extraer y puntuar. No sirve para escribir código, contar ni hacer cuentas.


    ¿Qué es Jev, el System One Model de TypeSafe AI?

    Jev —que el fabricante escribe así, no "JEV"— es un "System One Model": un modelo que convierte estado no estructurado en decisiones tipadas con probabilidades calibradas, sin generar ni una línea de texto libre. Es el primer modelo de TypeSafe AI y se lanzó el 15 de septiembre de 2026.

    Lo aclaro porque el acrónimo en mayúsculas ya estaba cogido: JEV es, en literatura médica, el virus de la encefalitis japonesa. A partir de aquí lo escribo como lo escriben ellos.

    El nombre viene de Kahneman. El Sistema Dos delibera y escribe: eso es un LLM. El Sistema Uno responde por reflejo, y su salida no es prosa sino un juicio. Jev es lo segundo, con la parte cara amputada.

    El truco es arquitectónico: evalúa todas las preguntas a la vez contra el mismo estado, en paralelo y de forma independiente, en lugar de construir una respuesta token a token. Por eso añadir preguntas apenas cambia el tiempo de respuesta. Y por eso no puede explicarte por qué decidió lo que decidió: no está entrenado para generar texto, así que no hay prosa donde escribirlo.

    Está entrenado exclusivamente con datos sintéticos, y con un método distinto: RLCD, Reinforcement Learning for Calibrated Decisions.

    ¿En qué se diferencia RLCD de RLHF?

    RLHF optimiza que la respuesta le guste a un evaluador humano. De ahí que los LLM suenen igual de seguros inventando que acertando: la seguridad puntúa bien. RLCD optimiza otra cosa — que las probabilidades sean epistémicamente honestas.

    Calibrado significa esto: de todo lo que el modelo responde con un 90% de probabilidad, debería acertar alrededor del 90% de las veces. No el 99% ni el 60%. El número significa lo que dice.

    Si has peleado con logprobs sabes por qué importa: esos números existen, pero no están calibrados. Un 0.95 no te promete 19 aciertos de cada 20. Jev dice que sí — y es la única promesa del lanzamiento que puedes verificar tú en una tarde: agrupa tus respuestas por tramo de probabilidad y mira qué porcentaje acierta cada tramo. Si cuadra, sobre eso puedes escribir un if.

    Es el mismo fondo que expliqué en por qué la IA se inventa cosas: el problema no es que el modelo se equivoque, es que se equivoca con el mismo tono con el que acierta.

    Las tres primitivas: noul, choice y score

    La API es un endpoint REST, POST https://api.typesafe.ai/v1/systemone, con bearer token. Le mandas tres cosas: model (por ejemplo jev-latest), state —string, objeto JSON o array de texto— y questions.

    Las preguntas referencian campos del estado con notación de backticks: `ticket`, `mensaje`. Y solo hay tres tipos:

    • noul — binaria sí/no. Devuelve una probabilidad de 0 a 1.
    • choice — elige entre opciones que tú defines. Devuelve la opción, la distribución completa de probabilidad y un confidence de 0 a 1.
    • score — sitúa algo en una escala ordenada. Devuelve la media ponderada, la distribución y un confidence.

    Con el SDK de JavaScript, @typesafe-ai/sdk, se ve así:

    import { choice, noul, score, TypeSafeClient } from '@typesafe-ai/sdk'
    
    const client = new TypeSafeClient()
    
    const { answers } = await client.systemOne({
      state: { ticket },
      questions: {
        category: choice('What kind of ticket?', { bug: '...', billing: '...' }),
        severity: score('How severe?', ['Low', 'Medium', 'High'])
      }
    })
    

    Hay SDK de Python equivalente (from typesafe_sdk import Choice, Noul, TypeSafeClient, con client.system_one(...)) e integración con el Vercel AI SDK vía experimental_evaluate() y typeSafeAi.evaluationModel('jev-latest'), o con el string de gateway 'typesafe-ai/jev'. — esto último está documentado del lado de Vercel, no en la doc de TypeSafe.

    Fíjate en lo que no hay en ese código: ningún prompt pidiendo "responde solo con JSON". Ninguna función de reparación. Ningún reintento. El tipo no es una súplica al modelo, es la superficie de salida del modelo.

    Si vienes de montar esto a mano con schemas y validación —el camino que recorro en diseñar schemas Zod para LLM— el contraste es incómodo: la mitad de ese andamiaje deja de tener función. La otra mitad no — los tipos siguen siendo tuyos en cuanto la respuesta entra en tu dominio, y eso lo trabajo entero en el curso de Zod para TypeScript.

    Caso práctico: triaje de tickets con umbrales

    El patrón que mejor rinde es el fan-out especulativo: preguntar de golpe todo lo que no dependa de nada. Sale más barato que la cadena secuencial equivalente.

    const { answers } = await client.systemOne({
      model: 'jev-latest',
      state: { ticket },
      questions: {
        category: choice('What kind of ticket is `ticket`?', {
          bug: 'Something in the product is broken',
          billing: 'Charges, invoices or refunds',
          feature: 'A request for something that does not exist yet',
          other: 'Anything else'
        }),
        severity: score('How severe is `ticket`?', ['Low', 'Medium', 'High', 'Critical']),
        impact: score('How many users does `ticket` affect?', ['One', 'Some', 'Many']),
        isAngry: noul('Is the author of `ticket` frustrated?')
      }
    })
    

    Una request, cuatro decisiones, ~250 ms medidos desde mi red. Y ahora la parte que decide si esto es ingeniería o un juguete: qué haces con el confidence.

    const { category, severity, impact, isAngry } = answers
    
    // La doc sugiere estos umbrales; ajústalos con tus propios datos.
    if (category.confidence < 0.5) {
      return escalarAHumano(ticket, { motivo: 'clasificación poco concentrada' })
    }
    
    // `score` es la media ponderada sobre los índices de nivel: 0..3 con cuatro
    // niveles, 0..2 con tres. Normalízalo antes de mezclar escalas distintas.
    // `noul` es directamente la probabilidad de que la respuesta sea sí.
    const prioridad =
      0.5 * (severity.score / 3) +
      0.3 * (impact.score / 2) +
      0.2 * (isAngry.noul > 0.7 ? 1 : 0)
    
    // `category.choice` es la opción ganadora; `category.probabilities`, la distribución completa.
    enrutar(category.choice, prioridad)
    

    Cada respuesta llega tipada bajo el id que le pusiste en la request: un choice
    trae choice, probabilities y confidence; un score trae score,
    probabilities, confidence y un legend con la descripción de cada nivel; un
    noul trae solo noul, la probabilidad de sí. La distribución suma 1, pero son
    floats: si la compruebas en un test, hazlo con tolerancia
    (Math.abs(suma - 1) < 1e-6), nunca con igualdad exacta. Y mira usage, que
    llega junto a answers y model con los tokens de entrada y salida: el coste
    real por decisión lo registras, no lo estimas.

    El confidence mide cuán concentrada está la distribución. Por debajo de 0,5, la doc recomienda no actuar sin revisión humana. Y para acciones destructivas —borrar, reembolsar, banear— pide confirmación aunque estés por encima de 0,9.

    Ese segundo umbral es el que todo el mundo se salta. Un número alto no es un permiso. Es la misma lógica de degradación controlada que aplico con los circuit breakers en agentes de IA: decidir de antemano qué pasa cuando el sistema duda.

    El scoring compuesto tiene una ventaja que no es de rendimiento: es auditable. Ese 0.5 / 0.3 / 0.2 lo discutes en una PR. Un prompt que dice "decide la prioridad del ticket" no lo discutes: lo reescribes y rezas.

    ¿Jev o un LLM con structured output?

    Esta es la comparación honesta, porque un LLM con salida estructurada ya funciona. Lo que Jev aporta no es capacidad nueva: es velocidad, coste y probabilidades que significan algo.

    Jev (System One) LLM con structured output
    Qué devuelve decisión tipada + distribución + confidence JSON validado contra tu schema
    Texto libre, código, prosa no, por diseño sí
    Latencia típica ~250 ms end-to-end medidos (~100 ms de inferencia) segundos
    Coste de entrada $0,042 / millón de tokens $0,20 – $10 / millón
    Coste de salida gratis ~5x el de entrada
    Probabilidades calibradas (RLCD) logprobs sin calibrar, o nada
    Aritmética, fechas, conteo no fiable mejor, aunque también frágil
    Límite / riesgo puede devolver un valor de tipo válido y completamente equivocado, con confidence alta; exige diseñar a mano el espacio de respuestas alucina contenido dentro de un JSON perfectamente válido; coste y latencia escalan mal con el volumen

    Haz la cuenta en vez de tragarte el titular. La home dice "193,6x más rápido, 444,6x más barato": con $0,042 de entrada frente a $0,20–$10, el ahorro solo en entrada va de unas 5x a unas 238x, y el 444x solo cuadra si además sumas la salida —gratis aquí, unas cinco veces la entrada allí— con un mix de tokens que no publican.

    Con la velocidad pasa lo mismo. Coge mi propia apertura, dos segundos, y lo que mido de verdad, 258 ms: eso son 8x, no 193x. El 193,6x sale de workflows con muchas preguntas independientes, donde el LLM las resuelve en cadena y Jev las resuelve de una tacada. Es una comparación de arquitectura de workflow, no de modelo contra modelo.

    Y ese “~100 ms” tampoco es lo que vas a medir tú. Lo he cronometrado contra la API real: 12 llamadas abriendo conexión nueva cada vez dan una mediana de 628 ms; reutilizando la conexión TLS, 258 ms, y nunca por debajo de 213. Solo el handshake TCP + TLS son 342 ms. Los 100 ms son tiempo de inferencia —ciertos, si tu código corre al lado de sus GPUs—; el resto es el viaje hasta San Francisco. Si te llevas una sola cosa de aquí que sea esta: reutiliza la conexión, son 2,4x gratis.

    El propio blog de TypeSafe rebaja eso a 40x–200x para inteligencia equivalente en tareas System One, y admite que esas cifras "están en el extremo alto de las ganancias del mundo real". Midieron contra la media de GPT-6 Astra y Fable 5.1, con precios de LLM sacados de OpenRouter —lo que "casi con certeza" introduce sesgo— y desde la costa oeste de EEUU.

    El número que yo me creo viene de fuera: Vercel midió el clasificador de seguridad de su modo automático de fx y le salió entre 5x y 18x más rápido en p95 con Jev que con gpt-5.6-luna, su opción anterior, y además con más acierto. Lo contaron su propio CEO y el ingeniero que hizo la prueba, y lo recogió TechCrunch. Tampoco es un árbitro imparcial —Jev viene integrado en su AI SDK, como has visto arriba—, pero al menos no vende el modelo, la carga de trabajo es real y el rango que publica es mucho más modesto que el de la home.

    ¿Cuándo NO conviene usar Jev?

    Para esto, el hilo de Hacker News sobre el lanzamiento —más de 1.900 puntos y cerca de 500 comentarios— es más útil que la documentación oficial.

    No puede escribir nada. Ni código, ni explicaciones, ni un resumen. Lo resumió un comentarista: "esto es probablemente súper útil para clasificación, routing y scoring, pero no se parece en nada a los modelos de generación de código que todos usamos hoy". El título original del post prometía un "nuevo modelo frontier" y hubo que editarlo.

    No sabe contar ni hacer cuentas. Aritmética, fechas y comparaciones numéricas se quedan en tu código. No delegues un "¿han pasado más de 30 días?" a Jev; eso es un if.

    El "no puede alucinar" es marketing. Lo que garantiza por diseño es que la salida es de un tipo válido: cero errores de tipo. Pero la objeción más votada del hilo lo parte por la mitad: "claro que no puede emitir un tipo inválido, pero sí puede emitir un valor válido completamente equivocado. También puedes forzar salida estructurada en un LLM". Un valor equivocado con confianza alta sigue siendo una alucinación cuando llega a tu base de datos.

    Es frágil con lenguaje difuso. El ejemplo del hilo: ante "quiero que tu agente me llame mañana a las cinco", la pregunta "¿el usuario quiere hablar con un agente humano?" responde que sí y se traga entero el matiz temporal. Esa segunda dimensión tienes que haberla previsto tú. Traducido: el espacio de respuestas lo diseñas tú, a mano y con cuidado. Jev no descubre categorías, puntúa las que le das. Si tu taxonomía está mal, la salida está mal — y con confidence alta.

    Piensa en inglés. La ficha del modelo lo dice sin adornos: el inglés es su idioma principal de entrenamiento y donde hoy la precisión es mejor. Otros idiomas funcionan, con menos puntería. Por eso las preguntas de los ejemplos de arriba están en inglés aunque el ticket entre en castellano: el state déjalo en el idioma que llegue, pero las preguntas, las opciones y los niveles escríbelos en inglés. Si los pones en español, mídelo antes de fiarte.

    El state sucio le baja la puntería. El detalle irrelevante actúa de distractor. Manda los campos que importan, no el objeto entero que te escupe el ORM.

    Y no trata el estado como hostil. Esto lo admite su propia página de limitaciones: una instrucción inyectada, un encuadre engañoso o un texto que argumenta a favor de su propia clasificación pueden mover la respuesta. Si lo que clasificas lo escribe un usuario —o peor, otro modelo—, el estado es una superficie de ataque, no un dato. Describe los dos lados de cada pregunta y prueba con entradas envenenadas antes de darle poder sobre nada.

    Solo lee texto. Nada de imágenes, audio ni vídeo: lo que no sea texto hay que preprocesarlo antes de mandarlo como estado. Y hay un techo de 255 opciones en una elección de una sola etapa — por encima toca hacer scoring en dos pasadas.

    Y ojo con las demos. La de Doom impresiona hasta que ves que al modelo le pasaban el estado del juego ya estructurado —coordenadas, ángulos—, no píxeles. La de Home Assistant sí convenció a bastante gente, pero tuvieron que saltar a un modelo de Anthropic para partir peticiones con varias intenciones.

    Donde sí le veo el hueco es en lo que nadie automatiza porque sale caro: deduplicar registros, casar entidades, revisar miles de filas una por una. Ahí velocidad y confianza calibrada sí cambian el juego.

    Qué cambia esto si construyes agentes

    Un agente es un bucle que decide muchas veces y actúa alguna. La mayoría de esas decisiones —¿consulta o queja?, ¿hace falta buscar?, ¿esto es urgente?, ¿me paro?— no necesitan prosa: necesitan un juicio rápido y honesto que hoy paga un modelo enorme escribiendo un párrafo para devolver una palabra.

    A 250 ms y coste casi nulo deja de tener sentido racionar decisiones. Esa es la tesis: Jev no compite con tu LLM, compite con los if que escribiste porque llamar al LLM salía caro.

    Con dos condiciones que no negocio. La primera, que midas: la viabilidad se calcula en coste por tarea resuelta, no por token, y sin evals sobre lo que genera la IA estás comparando titulares. La segunda, que las decisiones caras pasen por un contrato explícito — el método que explico en el ebook gratuito Revisión por Contrato.

    Hoy mismo puedes hacer esto: coge la llamada a un LLM más tonta y repetida que tengas en producción —la que solo devuelve una etiqueta—, reescríbela como un choice con su confidence y ponle un umbral de 0,5 con salida a revisión humana. Es una tarde. Si el número no mejora, lo sabrás con datos y no con una home.

    Y si quieres montar el agente entero con esta cabeza, el recorrido de idea a producto está en el curso Construye con IA.

    Si quieres ir más allá de este resumen, lo he escrito entero en un libro: Jev y las decisiones tipadas con IA. Las tres primitivas con la forma real de sus respuestas, cómo comprobar la calibración con tus propios casos, cinco patrones de producción y un capítulo entero sobre cómo te va a fallar. Cada cifra lleva su origen declarado.

    Preguntas frecuentes

    ¿Jev sustituye a mi LLM?

    No, y no lo pretende. Jev no escribe código, ni resúmenes, ni respuestas a un usuario. Sustituye a las llamadas de tu pipeline que solo devuelven una etiqueta, un booleano o una nota del 1 al 5. El LLM se queda para generar.

    ¿Qué significa exactamente que las probabilidades estén calibradas?

    Que el número es verificable: de todo lo que Jev responde con un 90% de probabilidad, acierta cerca del 90% de las veces. Es lo que optimiza RLCD, frente a RLHF, que optimiza que la respuesta le guste a un humano y produce modelos que suenan igual de seguros acertando que inventando.

    ¿Puede alucinar Jev?

    Sí, en el sentido que importa. Lo que no puede es devolver un tipo inválido: eso está garantizado por diseño, no por estadística. Pero puede devolver una opción perfectamente válida y equivocada, y hacerlo con confianza alta. Trata el confidence como una señal de enrutado, nunca como una garantía de verdad.

    ¿Cuánto cuesta y cuánto tarda?

    $0,042 por millón de tokens de entrada y salida gratis. Su documentación habla de unos 100 ms de inferencia; medido desde mi red, el end-to-end fue de 258 ms de mediana y nunca bajó de 213 ms. Los límites publicados: 250.000 tokens por segundo, 1.200 requests por minuto y dos techos de contexto que conviene no confundir — 64.000 tokens por request contando estado y preguntas juntas, y 32.000 para el estado más la pregunta más larga.

    ¿Qué hago si tengo más de 255 opciones?

    Ese es el techo de una elección de una sola etapa. Por encima toca scoring en dos pasadas: reduces el espacio con un score sobre categorías gruesas y luego haces el choice fino dentro del grupo ganador. Sigue saliendo más barato que encadenar llamadas a un LLM, pero deja de ser gratis en complejidad.


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

  • El futuro de los agentic systems: coste por tarea, no benchmarks

    El futuro de los agentic systems: coste por tarea, no benchmarks

    La iteración 14 me costó más que las trece anteriores juntas.

    El agente no hizo nada raro. Leyó el repo, lanzó los tests, leyó los logs. En la iteración 14 una tool devolvió 5.000 tokens de logs que no necesitaba nadie, el contexto cruzó un umbral de facturación y la misma tarea pasó a costar el doble. No un 5 % más. El doble.

    Ahí está el techo real de los agentic systems, y no tiene nada que ver con la inteligencia del modelo. El futuro de los agentic systems no lo decide lo listo que sea el siguiente checkpoint: lo decide que hoy no puedo predecir lo que va a costar una tarea ni demostrar que el resultado es correcto sin leérmelo entero.

    Esta es la tesis, y la voy a defender con números que ya he publicado aquí:

    El techo de los agentic systems no es la capacidad del modelo. Es que nadie puede pagarlos de forma predecible ni auditar lo que producen. El futuro de los agentic systems se decide en el coste por tarea y en el harness de verificación (verification harness), no en el próximo benchmark.

    Los benchmarks van a seguir subiendo. Es lo único que tengo claro del próximo año. Lo que no va a subir solo es tu capacidad de presupuestar una tarea y de firmar que salió bien.

    El precio por token ya no te dice lo que vas a pagar

    El precio por token dejó de predecir la factura porque el mismo modelo puede mantener la tarifa y aun así consumir más tokens por tarea, cruzar un umbral de precio escalonado o abrir más subagentes.

    Durante dos años elegimos modelo mirando una tabla de dos columnas: dólares por millón de input, dólares por millón de output. Era una multiplicación. Funcionaba.

    Ya no.

    Mira Gemini 3.8 Flash. Mismo precio por token que 3.7 Flash: 0,75 $ de input y 3,75 $ de output por millón. Cero subida. Google no tocó la tarifa.

    Pero el modelo gasta un 30 % más de tokens de salida por tarea: 48.000 de media. Razona más, escribe más y encadena más turnos. Y cada turno extra reenvía el contexto acumulado, que se factura como entrada: de los 0,58 $ que cuesta hoy una tarea, solo unos 0,18 $ son salida. El resto es contexto reenviado. Resultado medido con el reasoning en high: alrededor de un 40 % más caro con la tarifa intacta.

    El precio se quedó igual y la factura subió un 40 %. Las dos cosas son ciertas a la vez.

    Y hay fecha de caducidad: esos 0,75 $ y 3,75 $ son precio introductorio hasta el 31 de diciembre de 2026. El 1 de enero de 2027 pasan a 1,50 $ y 7,50 $ — exactamente el doble. Si has calculado tus márgenes con la tarifa de 2026, tu coste por tarea ya tiene una subida programada en el calendario.

    Ese es el primer mecanismo: la verbosidad. El segundo es peor, porque no es gradual. Es un escalón.

    En la API de GPT-6 Astra la tarifa es 10 $ de input y 50 $ de output por millón. Pero cualquier request que supere los 272.000 tokens de input dobla el precio de input y de caché y multiplica el output por 1,5. Y no sobre el exceso: sobre la request entera.

    Los números del ejemplo que desglosé allí:

    Request Input Output Coste
    Por debajo del umbral 270.000 8.000 3,10 $
    Por encima del umbral 275.000 8.000 6,10 $

    Un 1,9 % más de tokens de input. Un 97 % más de factura.

    Y esto es un problema de arquitectura, no de hoja de cálculo: tú no decides cuándo se cruza el umbral. Lo decide el agente, en la iteración 14, cuando una tool devuelve más logs de la cuenta. Tu prompt inicial ocupaba 12.000 tokens. El contexto acumulado en el bucle es el que cruza la línea.

    El tercer mecanismo es todavía más silencioso: la propensión a delegar en subagentes cambia con el modelo. Claude Opus 5 delega más fácilmente que sus antecesores. Mismo código, mismo prompt, misma tarea — y de repente tienes varios subagentes con su propio contexto donde antes había uno.

    Tres formas de que tu factura suba sin que toques una línea de código: el modelo se vuelve más hablador, el contexto cruza un escalón, o el sistema decide abrir más ramas.

    Ninguna aparece en la tabla de precios. Por eso la métrica que sobrevive es el coste por tarea (cost per task): lo que cuesta resolver una unidad de trabajo completa, de principio a fin, incluyendo reintentos, tools, subagentes y las veces que el agente se equivoca y vuelve a empezar.

    Es la única cifra que puedes meter en un presupuesto y defender delante de alguien que no sabe qué es un token.

    Dónde no está el coste por tarea de un agente

    Voy a decir algo impopular: el framework que elegiste no es tu problema de coste.

    Circula la leyenda de que los frameworks de agentes te meten 1.500 tokens ocultos en cada llamada. Lo medí de verdad: el bloque de herramientas pasó de 213 bytes a 294 o 299 bytes según el framework, y la petición entera de 393 a 556 bytes. Unos 40 tokens de diferencia.

    Cuarenta. Con un coste por tarea de 0,58 $, eso es ruido estadístico.

    Mientras tanto, el bucle de ese mismo agente acumula 270.000 tokens de contexto y roza un escalón que dobla la factura entera.

    Quien se pasa la tarde optimizando el overhead del framework está limpiando el mostrador mientras se le inunda el sótano. El coste de un agentic system vive en el bucle: en cuántas iteraciones hace, en cuánto contexto arrastra, en qué devuelven las tools y en cuántas veces reintenta porque nadie le dijo que ya había terminado.

    Generar es barato. Auditar no ha bajado de precio

    El coste de generar código cae cada trimestre. El de verificarlo no ha bajado ni un céntimo, porque lo sigue pagando una persona leyendo diffs. Esta es la segunda mitad de la tesis, y la que casi nadie quiere mirar.

    La asimetría es brutal y va a peor: la unidad de trabajo del agente ya es la tarea; la unidad de trabajo del revisor sigue siendo la línea. Un agente cierra en cuatro minutos un refactor que tardas cuarenta en revisar. Multiplica eso por cinco agentes en paralelo y la cola de revisión se convierte en el proceso más lento del equipo.

    Lo que pasa entonces no es que la gente revise más rápido. Es que deja de revisar. Aprueba por cansancio. Y el sistema pierde la única propiedad que lo hacía utilizable: que alguien podía responder de lo que salía.

    La salida no es revisar más. Es dejar de revisar a ojo.

    Un agente en producción necesita que la verificación sea una función que devuelve verdadero o falso, no una opinión. Eso significa dos cosas concretas: evals deterministas (deterministic evals) que corren en CI y tumban el build, y un contrato explícito de lo que la tarea debía cumplir, escrito antes de que el agente empezara.

    Sin contrato previo no hay verificación posible: solo hay un humano decidiendo a posteriori, y con sesgo de confirmación, si le gusta lo que ve. Ese método lo escribí entero en el ebook gratuito Revisión por Contrato: el contrato se escribe antes, no después.

    Es el mismo motivo por el que llevo dos años empezando cada feature por la especificación y no por el código, y la mecánica completa está en el libro de Spec-Driven Development.

    El harness pesa más que el modelo

    El harness —el código que envuelve al modelo— cambia la puntuación de un mismo modelo más de lo que la cambia sustituir el modelo entero. Si solo te llevas un dato de este post, que sea este.

    ARC-AGI-3 le dio a GPT-6 Astra un 99,9 %. Ese número recorrió internet como prueba de que habíamos llegado a la AGI.

    Salió del adapter propio de OpenAI: un harness con estado, optimizado para el benchmark. Con el harness estándar —el que sí compara entre proveedores— la nota fue 62,7 %, justo en el techo del rango de aproximadamente 17 % a 63 % que la propia organización del benchmark da para las llamadas stateless: las que hace tu código.

    Mismo modelo. Mismos pesos. La misma semana.

    De 62,7 a 99,9 con los mismos pesos y la misma semana, solo cambiando lo que hay alrededor. Y por debajo del 62,7 está todo lo que consigue un arnés peor montado que el de ARC.

    Traducción para tu backlog: la diferencia entre un prototipo que funciona a medias y un sistema que cierra tareas de verdad no está en cambiar de modelo. Está en el harness: el bucle de acciones, el formato de las observaciones, la gestión del contexto, el estado entre pasos, las condiciones de parada.

    Eso es código tuyo. No es un proveedor. No lo compras con una API key.

    Y por eso el próximo salto de calidad en agentic systems no va a venir de un checkpoint nuevo, sino de cosas mucho más aburridas: compactar el contexto antes de cruzar un umbral, truncar lo que devuelve una tool, cachear lo que no cambia y montar un circuit breaker que mate el bucle cuando el coste acumulado o el número de iteraciones se disparan.

    Un agente sin condición de parada no es autónomo. Es una fuga.

    El futuro de los agentic systems: cuatro predicciones para 2027

    Cuatro predicciones sobre el futuro de los agentic systems, ordenadas por lo seguro que estoy de cada una: (1) el coste por tarea se convierte en un requisito no funcional, (2) la unidad de facturación se desplaza del token a la tarea, (3) el harness se estandariza y el modelo se vuelve intercambiable, y (4) la métrica pública pasa a ser el porcentaje de tareas cerradas sin intervención humana.

    Aquí dejo de describir el presente y me mojo.

    El coste por tarea se convierte en un requisito no funcional. Igual que hoy escribes "p95 por debajo de 200 ms" en una spec, vas a escribir "coste por tarea por debajo de 0,40 $". Con su alerta, su panel y su presupuesto que corta. Un agente que resuelve la tarea pero cuesta cuatro veces lo presupuestado es un incidente, no un éxito. (La que más firme: 18 meses.)

    La unidad de facturación se desplaza del token a la tarea. Ya está pasando en las herramientas de coding agéntico: nadie te vende millones de tokens, te vende sesiones y límites de uso. Quien pueda ofrecer "tarea cerrada o no cobro" tendrá una ventaja de precio que nadie podrá igualar sin verificación automática. (Probable en 2027; ya ha empezado.)

    El harness se estandariza y el modelo se vuelve intercambiable. Los proveedores ya no compiten solo por la nota del leaderboard: compiten por ser el sustrato del bucle —herramientas, estado, ejecución, adapters propios—. El 99,9 % de Astra salió exactamente de ahí. Cuando el harness sea portable de verdad, cambiar de modelo será una línea de configuración, y tu ventaja competitiva estará entera en tus evals y en tus contratos de verificación, que son los únicos activos que no te da el proveedor. (La más arriesgada. Si dentro de dos años cambiar de modelo sigue costando una semana de trabajo, me habré equivocado.)

    La métrica pública será el porcentaje de tareas cerradas sin intervención humana. No la puntuación en un benchmark de puzzles: tareas reales, cerradas de principio a fin, verificadas por una función. Y al lado, el coste medio de cada una. Llamémoslo por su nombre: tasa de cierre autónomo (autonomous closure rate) y coste por tarea cerrada. Ese par de números va a decidir presupuestos, y hoy casi nadie lo instrumenta. (La más lenta: tres años, y solo si alguien con cuota de mercado publica la cifra primero.)

    Capacidad sin economía ni verificación es una demo. Y las demos no entran en producción.

    Qué hacer hoy: instrumenta el coste por tarea

    Una sola cosa, y esta semana.

    No hace falta un stack de observabilidad. Hace falta que cada ejecución de tu agente escriba una línea con seis campos:

    1. tarea — un identificador de la unidad de trabajo, no del prompt
    2. tokens_input — acumulados en todo el bucle, no los de la primera llamada
    3. tokens_output — acumulados, incluidos los de los subagentes
    4. iteraciones — cuántas vueltas dio el bucle antes de parar
    5. coste — calculado con la tarifa vigente, tramo premium incluido si cruzó el umbral
    6. eval_ok — verdadero o falso, salido de una función, no de una impresión

    Diez minutos de código.

    Cuando tengas cincuenta filas vas a ver dos cosas que ahora no ves: que el 80 % del coste se lo come un puñado de tareas, y que hay tareas que el agente "termina" sin cerrar de verdad. Esas dos cifras valen más que cualquier comparativa de modelos que leas este mes.

    Si quieres montar esto entero —del bucle a la verificación— sin aprenderlo a base de facturas, lo enseño paso a paso en Construye con IA. Y si prefieres ver cómo lo medimos sobre proyectos reales, con los harness y las evals que usamos en producción, eso vive en Dominicode Labs.

    El próximo modelo será mejor que este. Da igual. Gana quien sepa lo que cuesta cada tarea y pueda demostrar que salió bien.

    Preguntas frecuentes

    ¿Qué es un agentic system?

    Un agentic system es un programa que le da a un modelo de lenguaje un bucle, herramientas y estado: el modelo decide la siguiente acción, el sistema la ejecuta, le devuelve el resultado y el ciclo se repite hasta que se cumple una condición de parada. La diferencia con un chatbot no está en el modelo, está en el bucle y en quién decide cuándo parar. Por eso su coste no se mide por llamada, sino por tarea completa.

    ¿Qué es el coste por tarea y por qué sustituye al precio por token?

    El coste por tarea es lo que pagas por resolver una unidad de trabajo completa: iteraciones del bucle, tools, subagentes y reintentos incluidos. El precio por token ya no predice la factura porque un modelo puede mantener la tarifa y consumir un 30 % más de tokens por tarea, como pasó con Gemini 3.8 Flash: mismo precio y un 40 % más caro por tarea resuelta.

    ¿Qué tengo que registrar en cada ejecución para medir el coste por tarea?

    Seis campos por ejecución: tarea, tokens de input y de output acumulados en todo el bucle, iteraciones, coste calculado y si pasó la verificación. Multiplica por la tarifa y divide entre las tareas cerradas con éxito, no entre las ejecuciones totales. Si cuentas los intentos fallidos como tareas, tu coste real queda infravalorado y el presupuesto se te romperá en producción.

    ¿Por qué el coste de verificar no baja aunque baje el de generar?

    Porque el coste de generar cae cada trimestre y el de verificar no: lo sigue pagando una persona leyendo diffs. Un agente cierra en minutos lo que tardas media hora en revisar, y con varios agentes en paralelo la cola de revisión pasa a ser el proceso más lento del equipo. La salida no es revisar más rápido, sino convertir la revisión en evals deterministas y contratos que devuelvan verdadero o falso.

    ¿Qué es un harness y por qué pesa más que el modelo?

    El harness es la capa de código que envuelve al modelo: el bucle de acciones, el formato de las observaciones, la gestión del contexto, el estado entre pasos y las condiciones de parada. Pesa más que el modelo porque el mismo GPT-6 Astra puntúa 62,7 % en ARC-AGI-3 con el harness estándar y 99,9 % con el adapter propio de OpenAI, la misma semana y con los mismos pesos. Esa diferencia no está en los pesos: está en código que escribes tú.

    ¿Cambiar a un modelo más barato reduce la factura?

    No necesariamente. La factura depende de cuántos tokens consume el modelo por tarea, de si el contexto cruza umbrales de precio escalonados y de cuánto tiende a delegar en subagentes. Un cambio de modelo altera las tres cosas a la vez sin que toques una línea de código, así que la única forma de saberlo es medir el coste por tarea antes y después.

    ¿Qué métricas debería vigilar en un agentic system en producción?

    Cuatro: coste medio por tarea cerrada, porcentaje de tareas cerradas sin intervención humana, iteraciones por tarea y tokens de contexto máximos dentro del bucle. Las dos primeras dicen si el sistema es viable económicamente; las dos últimas avisan antes de que una ejecución cruce un umbral de precio o se quede dando vueltas.


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

  • Zettelkasten para developers: ahora tus notas las lee un agente

    Zettelkasten para developers: ahora tus notas las lee un agente

    Hace unos meses le pedí a uno de mis agentes que redactara una sección concreta de una guía. Devolvió algo correcto, genérico y absolutamente vacío.

    Lo incómodo es que yo ya había escrito eso. Meses antes. Con el contexto, la decisión y el motivo por el que descarté la alternativa.

    El agente no lo encontró. Y cuando fui a buscarlo yo, tardé veinte minutos: estaba enterrado en la mitad de un markdown gigante sin título propio.

    Ahí entendí que mi problema con el Zettelkasten para developers no era de disciplina. Era de formato.

    La tesis de este post, después de un año largo escribiendo así: el método no ha cambiado nada. Lo que ha cambiado es quién lee las notas.

    En 2021 tomabas notas atómicas para tu yo futuro. En 2026 las tomas también para el agente que las va a recuperar, y ese lector es mucho menos indulgente que tú.

    Por si llegas sin contexto: el método Zettelkasten son las fichas que el sociólogo Niklas Luhmann usó durante décadas para escribir, con una caja de papel y ningún ordenador. Aplicado a programadores, el Zettelkasten para developers es esto: una idea por archivo, en markdown plano, con un título que afirma algo, autocontenida y enlazada a las demás. No es una carpeta de apuntes ordenada. Es un grafo consultable.

    Crédito: en esto me metió Tomas Vik, con su introducción al método y su retrospectiva un año largo después. Lo que viene es mi versión, con mis cicatrices.


    El conocimiento que solo entiendes tú ahora muere dos veces

    Durante años tomé notas como casi todos los programadores que conozco: capturas de pantalla, fragmentos copiados de la documentación, bullets sin verbo, enlaces con un "revisar esto" al lado.

    Eso no es una base de conocimiento, ni gestión del conocimiento personal (PKM). Es un vertedero ordenado alfabéticamente.

    El problema clásico ya era grave: un formato que solo entiendes tú, en el momento exacto en que lo escribiste, deja de entenderse a los tres meses.

    Lo nuevo es la segunda muerte. Si trabajas solo —yo opero Dominicode sin equipo— tus agentes son literalmente tu equipo, y ese equipo lee lo que dejaste escrito. Si lo que dejaste son bullets sin sujeto, tu equipo entero trabaja a ciegas.

    Una nota mal escrita ya no solo te penaliza a ti dentro de seis meses. Penaliza cada ejecución de cada agente que consulta ese repositorio, hoy.


    El Zettelkasten para developers ya no es productividad, es infraestructura

    La nota atómica pasó de consejo de productividad a requisito técnico el día en que dejó de leerte solo tú. Este es el giro que hace que esto merezca un post en 2026 y no en 2015.

    "Sé conciso" y "una idea por nota" eran consejos de higiene mental. Sonaban a gurú de productividad. Se podían ignorar sin consecuencias visibles.

    Hoy son requisitos técnicos de recuperación.

    Cuando indexas tu conocimiento para que un modelo lo consulte —lo que todo el mundo llama RAG— el texto se parte en fragmentos (chunking), esos fragmentos se convierten en embeddings y se recuperan por similitud semántica. Una nota de un solo tema, autocontenida y con un título que afirma algo es exactamente la unidad que ese índice recupera bien: cabe en muy pocos fragmentos y cada uno de esos fragmentos sigue significando algo por su cuenta.

    Una nota-cajón de sastre de cuatro mil palabras hace lo contrario. Se parte por la mitad de un razonamiento. El fragmento recuperado empieza con "por eso lo descartamos" y el sujeto de esa frase se quedó tres páginas más arriba. El modelo recibe un texto gramaticalmente correcto y semánticamente huérfano, y con eso improvisa. A eso lo llamamos alucinación, pero muchas veces es mala segmentación.

    Cómo se recuperan de verdad esos fragmentos —y por qué la búsqueda vectorial pura falla— lo desmonté en búsqueda híbrida y embeddings en producción.

    Nota-cajón Nota atómica
    Temas por archivo Varios Uno
    Título Una etiqueta: "Angular signals" Una afirmación: "Signals no sustituye a RxJS cuando necesitas cancelar una petición en vuelo"
    Al partirse en fragmentos El fragmento pierde el sujeto Cada fragmento sigue significando lo mismo
    Tú dentro de seis meses Hay que releer el archivo entero Decides si la abres sin abrirla
    Tu agente Recupera contexto huérfano e improvisa Recupera la unidad completa

    Mi ancla concreta: la base de conocimiento de Dominicode es un índice de búsqueda sobre markdown plano. Ahora mismo son 118 documentos y 455 fragmentos, repartidos en colecciones por ámbito —agents, _brand, _research, labs, videos, books, revenue— que los agentes consultan antes de escribir sobre cualquiera de esos ámbitos.

    Haz la división: sale por debajo de cuatro fragmentos por documento de media. No es un número mágico —los capítulos de libro tiran de esa media hacia arriba—, pero sí es el síntoma de la política editorial: documentos cortos y monotema. Cuando un archivo se dispara muy por encima de esa media, dentro hay dos o tres notas peleándose por el mismo sitio.

    La infraestructura de todo esto —el repo, la estructura, el gobierno vía CLAUDE.md— la conté en cómo montar un segundo cerebro con Claude Code. Este post va de la capa de encima: qué escribes dentro de cada archivo.


    El título es la nota

    Si me quedo con una sola regla de este año, es esta: el título tiene que afirmar algo, no nombrar un tema.

    No "Angular signals". Eso es una etiqueta.

    Sí "Signals no sustituye a RxJS cuando necesitas cancelar una petición en vuelo". Eso es una nota que puedes recuperar, contradecir o confirmar.

    Un título-afirmación hace tres cosas a la vez. Te obliga a tener una conclusión antes de escribir, en vez de acumular material. Le da al índice la señal más fuerte que va a recibir de ese documento. Y te permite decidir meses después si abres la nota sin abrirla.

    La segunda regla es más dura de lo que parece: la nota tiene que sobrevivir a ser leída sola. Sin la nota anterior. Sin la pestaña que tenías abierta ese día. Sin acordarte del proyecto.

    En la práctica eso significa desterrar los pronombres huérfanos. "Esto no funciona en producción" no es una nota. "El provideHttpClient con interceptores funcionales no captura errores lanzados dentro del resolver de una ruta" sí lo es.

    Así se ve el cambio en un archivo real:

    <!-- Antes: nota-cajón -->
    # Errores HTTP
    
    - esto no funciona en producción
    - revisar interceptores
    - preguntar a alguien
    
    <!-- Después: nota atómica -->
    # El interceptor funcional no captura errores lanzados dentro del resolver de una ruta
    
    `provideHttpClient(withInterceptors([...]))` solo ve lo que pasa por
    `HttpClient`. Si el resolver falla antes de lanzar la petición, el error
    sale por el router y el interceptor nunca se entera.
    
    Alternativa descartada: mover el try/catch a cada componente. Funciona,
    pero hay que repetirlo en cada ruta.
    

    El de abajo es más largo de escribir. Es el único de los dos que sigue sirviendo dentro de un año, y el único que un agente puede recuperar suelto.

    La autocontención es la misma propiedad que hace útil a un fragmento recuperado. Escribes para ti dentro de un año y, sin proponértelo, escribes para un recuperador vectorial. Es el mismo requisito con dos nombres.

    Es la lógica que aplicamos en el curso Construye con IA al montar la capa de contexto de un producto: el modelo no falla por falta de inteligencia, falla porque le llega texto sin sujeto.


    Los enlaces son el trabajo que no puedes delegar

    Enlazar es la única parte del Zettelkasten que no puedes automatizar, y es donde está todo el retorno. Aquí es donde mucha gente se baja.

    Enlazar una nota nueva con las que ya tienes te obliga a responder dos preguntas que no se contestan en piloto automático: ¿dónde encaja esto? y ¿contradice algo que ya escribí?

    La primera es de arquitectura. La segunda es la buena.

    Cuando una nota nueva choca de frente con una de hace ocho meses, ha pasado algo real: o aprendiste, o una de las dos estaba mal, o —lo más habitual— las dos son ciertas en contextos distintos y nunca habías delimitado cuál era cuál. Resolver ese choque suele producir una tercera nota, y esa tercera nota es la única de las tres que vale dinero.

    Ese momento no te lo da un agente. Un modelo te sugiere enlaces plausibles todo el día, y como sugerencia sirven. Lo que no tiene es la sensación de "un momento, esto no cuadra con lo que decidí en marzo". Esa fricción es el aprendizaje. Externalizarla es quedarte con el grafo y perder el motivo por el que existe.

    Lo que sale de ahí es un grafo, con las relaciones como dato de primera clase. Es la idea detrás de qué es graph engineering: la estructura entre las piezas transporta tanta información como las piezas.

    Con un efecto secundario que no esperaba: es el mejor antídoto contra el context drift que he encontrado. Cuando la conversación con el agente lleva dos horas derivando, las notas enlazadas son el punto fijo al que volver. No lo que el modelo cree recordar, sino lo que tú decidiste y escribiste.

    Y si viven en markdown, acaban siendo consultables como datos: notas huérfanas, enlaces rotos, temas que lo acaparan todo. Salud del grafo con una consulta, como conté en DuckDB + Obsidian.


    El coste honesto: esto es lento

    El coste real del método es el tiempo, y toca la parte que casi nadie escribe.

    Escribir así es lento. Mucho más lento que guardar el enlace y seguir. Leer para destilar una nota es más lento que leer, y bastante menos placentero. Dejé libros y documentación técnica a medias porque procesarlos a ese ritmo se volvió un trabajo, no una lectura.

    Y algo dejó de funcionar del todo: la ambición de capturarlo todo. Durante meses convertí en nota cosas que no lo merecían y el grafo se llenó de ruido que empeoraba la recuperación. Más notas no es mejor.

    Ahora tengo tres filtros. Si la respuesta a cualquiera de los tres es sí, no hay nota:

    • ¿Caduca en menos de dos semanas? Es un recordatorio, no conocimiento. Va a un issue, no al grafo.
    • ¿Existe upstream, es estable y está bien escrito? Enlázalo. Reescribir la documentación oficial de una librería es trabajo perdido que además envejece mal.
    • ¿Podrías reconstruirlo en cinco minutos buscando? No es tuyo todavía. Es información disponible, no conocimiento propio.

    Lo que sí merece nota casi siempre es lo mismo: la decisión, la alternativa que descartaste y el porqué. Eso no existe upstream, no está en la documentación de nadie, y es exactamente lo que te vuelven a preguntar dentro de un año.

    Es la misma forma de pensar que sostiene el libro de Spec-Driven Development: escribir la decisión antes que el código, porque la decisión es la parte cara.

    Y un último coste, más traicionero: optimizar el sistema en vez de usarlo. Reorganizar carpetas, cambiar de herramienta, diseñar la taxonomía perfecta. Todo eso se siente productivo y no produce nada.


    Qué cambiaría de mi Zettelkasten si empezara hoy de cero

    Cuatro cosas, y ninguna es instalar nada.

    1. Títulos-afirmación desde la primera nota. Renombrar cientos de archivos después es un trabajo horrible, y hasta que no lo haces la recuperación no mejora.
    2. Escribir pensando en el fragmento, no en el documento. Antes de guardar, pregúntate si un trozo suelto de esa nota, leído sin nada alrededor, sigue significando lo mismo. Si no, pártela.
    3. Colecciones por ámbito, no carpetas por tema. Los temas se solapan siempre y acabas con la misma nota en tres sitios. El ámbito casi nunca es ambiguo, y es lo que te permite filtrar la búsqueda.
    4. La nota de decisión antes que la nota de resumen. Los resúmenes de lo que leí envejecen. Las decisiones que tomé, con su alternativa descartada, siguen valiendo años después.

    Y una cosa que no cambiaría: no delegar los enlaces.


    Empieza por la siguiente nota

    No migres nada. No reorganices el vault. No elijas herramienta.

    Coge la última decisión técnica que tomaste esta semana —esa que discutiste contigo mismo durante veinte minutos— y escríbela como una sola nota, con un título que afirme algo y sin un solo pronombre sin sujeto. Después enlázala con lo más parecido que ya tengas escrito.

    Eso es todo. Repítelo cuando vuelva a pasar.

    Dentro de un año esa nota la vas a leer tú, medio dormido, buscando por qué hiciste lo que hiciste. Y la va a leer un agente que no tiene tu memoria, ni tu contexto, ni tu paciencia. Escríbela para el segundo: el primero sale beneficiado gratis.

    Si quieres el paso siguiente —cómo hacer que un agente use ese conocimiento sin inventarse la mitad— te lo dejé en el ebook gratuito Revisión por Contrato. Y si prefieres verlo montado sobre proyectos reales, con el repo y los agentes funcionando, está en Dominicode Labs.


    Preguntas frecuentes

    ¿Qué es exactamente el Zettelkasten para developers y en qué se diferencia de tomar apuntes?

    El Zettelkasten para developers es un sistema de notas atómicas en markdown: una idea por archivo, con un título que afirma algo, autocontenida y enlazada con las demás. La diferencia con tomar apuntes es el enlace. Los apuntes se acumulan en carpetas y se consultan por nombre de archivo; el Zettelkasten forma un grafo donde la relación entre dos notas transporta tanta información como las notas.

    En 2026 hay una segunda diferencia: una nota atómica es también la unidad que un índice vectorial recupera entera. Un apunte largo no.

    ¿Sigue teniendo sentido el Zettelkasten ahora que la IA me resume cualquier cosa?

    Tiene más sentido que antes, y por un motivo distinto al de siempre. La IA resume bien lo que está publicado; no puede resumir la decisión que tomaste tú, con la alternativa que descartaste y el motivo. Eso no existe en ningún corpus.

    Lo que sí cambió es el destinatario. Antes escribías notas atómicas para tu yo futuro. Ahora las escribes también para el agente que las va a recuperar, y ese lector no perdona un pronombre sin sujeto.

    ¿Necesito una herramienta de Zettelkasten o me vale markdown en un repo?

    Te vale markdown en un repo, y es lo que uso. La única condición innegociable es que los archivos sean texto plano y tuyos: eso te da git, búsqueda y la posibilidad de indexarlos para que los lea un agente.

    Las herramientas específicas —Obsidian, Logseq, Roam— aportan comodidad al enlazar y visualizar el grafo. Pero elegir herramienta antes de tener notas es la forma más elegante de no empezar nunca.

    ¿Cuántas notas hacen falta para que esto empiece a servir?

    Menos de las que imaginas para el uso individual, y bastantes más para el efecto de red, que es cuando el grafo te sugiere conexiones que no habías visto.

    No te fijes un número, fíjate una señal: el sistema funciona el día que buscas algo, lo encuentras, y esa nota te resuelve el problema sin abrir nada más.

    ¿No puede el agente resumir mis notas largas y ahorrarme el trabajo?

    Puede resumir. El problema es cuándo. En el momento de la recuperación el agente ya no ve la nota entera: ve los fragmentos que el índice le devolvió —salvo que montes recuperación por documento padre, que es trabajo aparte—, y si están mal cortados el resumen es una reconstrucción sobre material incompleto.

    Usar un modelo para partir notas viejas en notas atómicas sí es buena idea. Lo que no puedes delegar es decidir qué idea va en cada nota, porque esa decisión es el contenido.

    ¿Esto sirve para documentación de equipo o solo para notas personales?

    Sirve para ambas, pero no son lo mismo. La documentación de equipo describe cómo funciona el sistema hoy y se actualiza cuando el sistema cambia. Las notas atómicas capturan por qué se decidió algo y se acumulan sin borrarse.

    Si trabajas en solitario la frontera casi desaparece: tu grafo de decisiones acaba siendo el onboarding de tus propios agentes.

    ¿Qué hago con las notas largas que ya tengo escritas?

    Nada, hasta que las necesites. Migrar el archivo entero es el clásico proyecto que se abandona a la tercera tarde.

    Aplica la regla al vuelo: la próxima vez que abras una nota vieja y te cueste encontrar dentro lo que buscabas, ese es el momento de partirla en dos o tres notas con título propio. Migras solo lo que demuestra que se usa.


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

  • Streaming SSE con Hono y Bun: la API de tu agente de IA

    Streaming SSE con Hono y Bun: la API de tu agente de IA

    El endpoint funcionaba. El agente respondía. El usuario veía una ruleta girando veintidós segundos y luego, de golpe, un muro de texto.

    Lo peor no fue la espera: abrió la misma pregunta en tres pestañas porque creyó que se había colgado. Tres ejecuciones del agente, tres facturas de tokens, una respuesta leída.

    Lo reescribí usando streaming SSE con Hono y Bun. Y el arreglo de fondo no fue técnico, fue conceptual: yo devolvía la respuesta de un agente como si fuera un JSON. Un agente no devuelve un resultado. Un agente transcurre. Piensa, llama a una herramienta, se equivoca, reintenta.

    Si tu API no transmite ese transcurso, el usuario solo ve una ruleta y saca sus propias conclusiones.

    Para exponer un agente por HTTP con salida en tiempo real, usa el helper streamSSE de hono/streaming sobre Bun: emite eventos con nombre (token, tool_call, error, done) en lugar de texto plano, propaga la desconexión del cliente a un AbortSignal con stream.onAbort() para dejar de gastar tokens, y manda un comentario SSE (: ping) cada 15-20 segundos para que ningún proxy corte la conexión. Todo el código de este post está verificado ejecutándolo contra Hono 4.13.7 sobre Bun 1.3.


    SSE no está deprecado: lo que se deprecó fue el transporte HTTP+SSE de MCP

    Son dos capas distintas y solo se deprecó una. Si vienes de mi post sobre montar un MCP Server en producción con Streamable HTTP y auth, su primer titular dice que SSE está deprecado, y ahora te propongo construir una API con SSE. No hay contradicción.

    Lo que se deprecó es el transporte HTTP+SSE del protocolo MCP —el de dos endpoints, uno GET para abrir el canal y otro POST para enviar, definido en la revisión 2024-11-05—, sustituido por Streamable HTTP en la 2025-03-26 y reclasificado formalmente como Deprecated en la 2026-07-28. Eso decide cómo hablan entre sí un cliente MCP y un servidor MCP.

    Server-Sent Events, el mecanismo del navegador, no está deprecado en absoluto. La especificación vigente de MCP —revisión 2026-07-28— sigue construida sobre él: el servidor responde a cada petición con un único objeto JSON o con un stream de Server-Sent Events, y el cliente está obligado a aceptar text/event-stream. En el registro oficial de features deprecadas la única entrada de transporte sigue siendo HTTP+SSE transport, deprecado en 2025-03-26, con Streamable HTTP como ruta de migración. Cambió la coreografía de endpoints, no el formato del stream. Aquí no implementamos MCP: construimos tu propia API para tu propio frontend.


    Por qué SSE y no WebSockets para un agente

    Porque el flujo de un agente es unidireccional: el usuario manda una pregunta y luego solo escucha. Abrir un canal bidireccional para eso es pagar complejidad por una dirección que nunca usas.

    Server-Sent Events (SSE) es el estándar web que permite a un servidor enviar un flujo de mensajes al cliente sobre una única conexión HTTP abierta, en texto plano y con el formato event: / data: / id:. Es unidireccional por diseño: el cliente abre la conexión y a partir de ahí solo recibe.

    La diferencia práctica está en lo que tienes que operar después del primer despliegue.

    SSE WebSockets
    Dirección Servidor → cliente Bidireccional
    Protocolo HTTP normal, respuesta larga Upgrade a ws://
    Proxies, CDN y balanceadores Pasa como cualquier respuesta HTTP Necesitan soporte explícito de upgrade
    Reconexión Automática en el navegador, con Last-Event-ID La implementas tú
    Autenticación Tus cookies o headers de siempre (con fetch) Handshake aparte, token en query
    Estado en el servidor Ninguno: es una request más Conexiones vivas que gestionar
    Depuración curl -N y lo lees Herramienta específica

    Elige WebSockets cuando el cliente tenga que interrumpir, corregir o hablar durante la generación: audio en vivo, edición colaborativa. Para un chat de agente con herramientas, SSE gana por aburrimiento operativo.


    Streaming SSE con Hono y Bun: el endpoint en veinte líneas

    El helper vive en hono/streaming y su firma es streamSSE(c, callback, onError?). Dentro del callback recibes un objeto de stream y escribes eventos con writeSSE().

    import { Hono } from 'hono'
    import { streamSSE } from 'hono/streaming'
    
    const app = new Hono()
    
    app.post('/agent', (c) =>
      streamSSE(c, async (stream) => {
        await stream.writeSSE({
          event: 'tool_call',
          data: JSON.stringify({ name: 'search_docs' }),
          id: '1',
        })
        await stream.writeSSE({ event: 'token', data: JSON.stringify({ text: 'Hola' }), id: '2' })
        await stream.writeSSE({ event: 'done', data: '{}' })
      })
    )
    
    export default { port: 3000, fetch: app.fetch }
    

    Ese export default { port, fetch } no es de Hono: es el contrato de Bun.serve. Bun arranca el servidor con bun run index.ts, sin adaptador ni servidor HTTP intermedio, y empuja cada chunk al socket según lo produces — que es justo lo que necesita un stream.

    El objeto que acepta writeSSE es { data, event?, id?, retry? }, con data como string o Promise<string>. Hono pone por ti Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive y Transfer-Encoding: chunked. Lo tienes documentado en el streaming helper de Hono.

    Lo que sale por el cable, verificado con curl -N, es exactamente esto:

    event: tool_call
    data: {"name":"search_docs"}
    id: 1
    
    event: token
    data: {"text":"Hola"}
    id: 2
    
    event: done
    data: {}
    

    Hono encaja aquí porque es un router sobre Web Standards: no te obliga a envolver la respuesta en abstracciones propias, y eso importa cuando lo que devuelves es un stream y no un objeto. La comparativa completa está en Hono vs NestJS vs Express.

    Si tu stack es NestJS, el mismo problema se resuelve de otra forma y lo cubrí aparte en streaming de respuestas de IA con NestJS y el Vercel AI SDK: allí el SDK gestiona el protocolo por ti sobre la Response nativa. Aquí el contrato de eventos lo defines tú, que es justo lo que quiero que controles.


    Eventos con significado, no un chorro de texto

    El error que veo en casi todas las implementaciones: mandar solo data: <trozo de texto> y que el cliente concatene. Con eso el frontend no puede renderizar estados, solo puede pintar letras.

    Un agente tiene fases visibles para el usuario. Dale un nombre a cada una.

    event data (JSON) Qué hace el cliente
    token {"text":"…"} Concatena en la burbuja de respuesta
    tool_call {"name":"search_docs","args":{…}} Muestra "Buscando en la documentación…"
    tool_result {"name":"search_docs","ok":true,"ms":412} Cierra el indicador de herramienta
    error {"code":"RATE_LIMIT","message":"…"} Pinta el fallo y ofrece reintentar
    done {"usage":{"input":812,"output":344}} Cierra el stream y guarda la conversación

    Ese contrato es una API pública aunque viva dentro de tu repo. Si mañana renombras tool_call a toolCall, rompes el frontend en producción sin que ningún compilador te avise: entre servidor y cliente solo viaja texto.

    Por eso defino el contrato como un discriminated union validado con Zod y lo importo en los dos lados. El servidor lo usa para serializar, el cliente para parsear. Si un evento no encaja con el schema, lo descartas y lo registras en lugar de romper el render. Es el patrón que enseño en el curso de Zod para validación y transformación de datos en TypeScript, aplicado al borde más frágil de una app de IA.

    Un detalle del formato: writeSSE parte tu data por saltos de línea y emite una línea data: por cada trozo. Con JSON.stringify no te afecta, porque produce una sola línea. Con texto crudo multilínea, sí.


    Cancelación: el usuario cierra la pestaña y tú sigues pagando

    Cuando el cliente se desconecta, Hono marca stream.aborted = true y dispara los listeners registrados con stream.onAbort(). Ese es el enganche para abortar el trabajo del agente.

    Aquí está el detalle que casi nadie cuenta, y lo verifiqué ejecutándolo: escribir en un stream muerto no lanza ninguna excepción. El write interno de Hono captura el error y sigue como si nada. Si tu bucle espera un try/catch para enterarse de la desconexión, va a seguir llamando al modelo hasta terminar la respuesta entera. Y la vas a pagar.

    app.post('/agent', (c) =>
      streamSSE(c, async (stream) => {
        const ac = new AbortController()
        stream.onAbort(() => ac.abort()) // el cliente se fue: corta el trabajo
    
        const agent = runAgent({ prompt: await c.req.json(), signal: ac.signal })
    
        for await (const chunk of agent) {
          if (stream.aborted) return // guardia explícita: no confíes en que write falle
          await stream.writeSSE({ event: 'token', data: JSON.stringify({ text: chunk }) })
        }
    
        await stream.writeSSE({ event: 'done', data: '{}' })
      })
    )
    

    Dos mecanismos, y quieres los dos. onAbort propaga la cancelación hacia abajo —al SDK del modelo, a tu fetch de herramientas, a la query de base de datos— porque casi todo el ecosistema acepta un AbortSignal. La guardia if (stream.aborted) return corta el bucle en el siguiente ciclo aunque la librería de turno ignore la señal.

    En mi prueba, con un cliente que abortaba a mitad de stream, onAbort se disparó en el mismo tick en que el bucle vio aborted = true, y el AbortSignal del agente quedó abortado.

    Hay un detalle propio de Bun que conviene conocer: onAbort depende de que el runtime cancele el ReadableStream de la respuesta, y Hono todavía arrastra una función isOldBunVersion() que considera antigua cualquier versión que empiece por 1.1, 1.0 o 0.. En esas escucha c.req.raw.signal para abortar el stream a mano. De Bun 1.2 en adelante funciona el camino nativo y no tienes que hacer nada.

    Esto es la contrapartida natural del agentic loop en producción con TypeScript: allí pones el techo de pasos para que el agente no se dispare solo, aquí pones el interruptor para que no siga corriendo cuando ya no hay nadie escuchando.


    Heartbeats: por qué tu stream muere a los sesenta segundos

    Porque los proxies inversos, los balanceadores y los CDN cierran conexiones que llevan demasiado tiempo sin transmitir bytes. En Nginx son los 60 segundos de proxy_read_timeout, su valor por defecto. Un agente pensando o esperando a una herramienta lenta produce exactamente ese silencio.

    La solución cabe en una línea. SSE define que toda línea que empieza por : es un comentario y el cliente la ignora:

    const beat = setInterval(() => {
      if (!stream.aborted) void stream.write(': ping\n\n')
    }, 15_000)
    
    stream.onAbort(() => clearInterval(beat))
    // y clearInterval(beat) también al terminar bien
    

    Y hay una segunda mitad que casi nadie configura: aunque mandes el heartbeat perfecto, un proxy con buffering activo va acumulando los eventos y entregándolos a golpes, así que el usuario sigue sin ver nada en tiempo real. Se desactiva con una cabecera, y la propia especificación de MCP la recomienda: los servidores SHOULD incluir X-Accel-Buffering: no al abrir un stream SSE, porque sin ella "los proxies pueden acumular mensajes antes de enviarlos al cliente".

    c.header('X-Accel-Buffering', 'no')
    

    Verificado en el cable: el ping viaja, no genera ningún evento en el cliente y mantiene la conexión con tráfico. Elige un intervalo por debajo del timeout de tu proxy: 15 segundos es seguro contra los 60 de proxy_read_timeout. En PaaS el corte llega antes y no lo decides tú — los timeouts de Render, Railway y Fly los desgloso en desplegar agentes LangChain en producción.

    Aprovecha también retry: al emitir { data: '…', retry: 3000 } le dices al navegador cuánto esperar antes de reconectar. Y si numeras los eventos con id, el navegador reenvía el último en la cabecera Last-Event-ID al reconectar, así que puedes reanudar en vez de empezar de cero. Eso solo aplica cuando el cliente es EventSource, y ahí viene el siguiente problema.


    El cliente: por qué EventSource se te queda corto

    Porque EventSource solo hace peticiones GET y no admite body ni headers personalizados. Para un agente necesitas mandar el prompt, el historial y un Authorization: o metes la conversación entera en la query string, o cambias de herramienta.

    Cambias de herramienta. fetch con un lector de stream y un parser de veinte líneas:

    async function* readSSE(res: Response) {
      const reader = res.body!.getReader()
      const decoder = new TextDecoder()
      let buffer = ''
    
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        buffer += decoder.decode(value, { stream: true })
    
        let sep: number
        while ((sep = buffer.indexOf('\n\n')) !== -1) {
          const raw = buffer.slice(0, sep)
          buffer = buffer.slice(sep + 2)
    
          let event = 'message'
          let id: string | undefined
          const data: string[] = []
          for (const line of raw.split('\n')) {
            if (line.startsWith(':')) continue // heartbeat
            if (line.startsWith('event:')) event = line.slice(6).trim()
            else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
            else if (line.startsWith('id:')) id = line.slice(3).trim()
          }
          if (data.length) yield { event, id, data: data.join('\n') }
        }
      }
    }
    

    Dos cosas se rompen si las improvisas. Los eventos llegan agrupados o partidos: en mi prueba el primer chunk traía dos eventos completos juntos, así que hay que bufferear y cortar por línea en blanco, nunca asumir un chunk igual a un evento. Y decoder.decode(value, { stream: true }) no es opcional: sin ese flag, un carácter multibyte partido entre dos chunks llega corrupto. En español eso es cualquier acento.

    El precio de dejar EventSource es que pierdes la reconexión automática y el Last-Event-ID. Si los necesitas, los implementas tú guardando el último id recibido y reenviándolo al reintentar. Cancelar, en cambio, es trivial: pasa un AbortController al fetch y llama a abort() cuando el usuario pulse "parar" o el componente se desmonte. Eso dispara todo el camino de cancelación de la sección anterior.


    El error a mitad de stream: ya enviaste un 200 OK

    Cuando el agente falla en el segundo 12, las cabeceras salieron hace 12 segundos. No hay un 500 que devolver. El fallo tiene que viajar dentro del stream, como un evento más.

    Hono lo contempla con el tercer argumento de streamSSE:

    app.post('/agent', (c) =>
      streamSSE(
        c,
        async (stream) => {
          // ...el agente...
        },
        async (err, stream) => {
          logger.error({ err }, 'agent stream failed')
          await stream.writeSSE({
            event: 'error',
            data: JSON.stringify({ code: 'AGENT_FAILED', message: 'No he podido completar la respuesta.' }),
          })
        }
      )
    )
    

    Dos avisos que solo se descubren mirando la respuesta cruda, y los comprobé.

    El primero: si pasas el tercer argumento a streamSSE, además de tu handler Hono emite automáticamente su propio evento error con el message de la excepción en crudo. Tu cliente recibirá dos eventos error por un solo fallo. Trátalo: quédate con el primero y descarta el resto hasta el cierre. Sin onError, en cambio, Hono no manda nada al cliente y la excepción se queda en un console.error del servidor.

    El segundo es de seguridad. Ese mensaje automático es el texto real de la excepción y va tal cual al navegador. Si tu error trae una URL interna, un nombre de tabla o un fragmento de credencial, acabas de filtrarlo. Lanza errores con mensajes ya saneados, o envuelve el cuerpo del handler en tu propio try/catch y nunca dejes que la excepción llegue al helper.

    Revisar este tipo de detalle en el código que genera un agente es lo que trabajo en el ebook gratuito Revisión por Contrato: un modelo te escribe este endpoint en treinta segundos, te devuelve el camino feliz impecable y te deja estos dos fallos intactos.


    Qué puedes montar hoy

    Coge tu endpoint de agente actual, el que devuelve un JSON al final, y cámbiale tres cosas: envuélvelo en streamSSE, emite token / tool_call / done en vez de un objeto final, y engancha stream.onAbort() a un AbortController que pases hacia abajo.

    Con eso dejas de pagar respuestas que nadie lee. El resto —heartbeats, reconexión, validación con Zod— lo añades cuando el primero se sostenga.

    Si quieres el flujo completo de idea a producto construyendo con agentes, lo enseño paso a paso en el curso Construye con IA.


    Preguntas frecuentes

    ¿SSE está deprecado en 2026?

    No. Lo que se deprecó fue el transporte HTTP+SSE del protocolo MCP, sustituido por Streamable HTTP en la revisión 2025-03-26. Server-Sent Events como mecanismo web sigue vigente y es estándar; en la revisión vigente 2026-07-28 Streamable HTTP lo sigue usando para la parte de streaming, respondiendo con Content-Type: text/event-stream. Son capas distintas: una es la coreografía de endpoints de MCP, otra es el formato del stream.

    ¿Cómo detecto en Hono que el cliente cerró la pestaña?

    Con stream.onAbort(callback) para reaccionar, y con la propiedad stream.aborted para comprobarlo dentro de tu bucle. Lo importante es no confiar en que la escritura falle: el write de Hono captura el error internamente y no lanza nada, así que un bucle sin la guardia if (stream.aborted) seguirá llamando al modelo y generando coste después de que el usuario se haya ido.

    ¿Puedo usar EventSource para llamar a mi endpoint de agente?

    Solo si tu endpoint es GET y no necesitas headers personalizados, porque EventSource no admite ni body ni Authorization. Para un agente al que le mandas prompt e historial, lo práctico es fetch con un parser propio del stream. Pierdes la reconexión automática y el manejo de Last-Event-ID, y si los necesitas los implementas tú guardando el último id recibido.

    ¿Cada cuánto debo mandar un heartbeat en un stream SSE?

    Cada 15 o 20 segundos, siempre por debajo del timeout de inactividad de tu proxy o balanceador — 60 segundos es el valor típico de Nginx. Se envía como un comentario SSE: una línea que empieza por dos puntos seguida de una línea en blanco, que el cliente ignora sin generar ningún evento. Recuerda limpiar el setInterval tanto al terminar bien como en onAbort.

    ¿Cómo devuelvo un error si ya envié las cabeceras con 200 OK?

    Emitiendo un evento error dentro del propio stream, porque el código de estado ya viajó. En Hono usas el tercer argumento de streamSSE. Ten en cuenta que Hono añade además su propio evento error con el mensaje crudo de la excepción, así que tu cliente recibirá dos, y conviene sanear los mensajes que lanzas para no filtrar detalles internos.

    ¿SSE o WebSockets para una app de chat con IA?

    SSE, salvo que el cliente necesite hablar durante la generación. El flujo de un chat con agente es una pregunta y luego solo escuchar, y SSE viaja sobre HTTP normal: atraviesa proxies y CDN sin configuración especial, reutiliza tu autenticación y no deja estado de conexión que gestionar. WebSockets compensa cuando hay audio bidireccional o interrupciones en vivo.


    Si quieres ver este endpoint construido en directo, con el agente conectado y midiendo la cancelación en tiempo real, lo publico en el canal de YouTube de Dominicode.

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

  • Claude Code en monorepos: dale solo la rebanada que necesita

    Claude Code en monorepos: dale solo la rebanada que necesita

    Un cliente me pasó su monorepo el mes pasado. Nueve paquetes, pnpm workspaces, Turborepo por encima. Le pedí a Claude Code algo ridículamente pequeño: cambiar el tipo de una prop en packages/ui.

    Tres respuestas después me estaba proponiendo tocar el cliente HTTP del backend.

    No era un modelo tonto. Era yo. Había arrancado la sesión desde la raíz del repo, y trabajar con Claude Code en monorepos desde la raíz significa una cosa muy concreta: le has dado nueve paquetes de superficie para una tarea que vive en uno.

    Esto no es el problema del que ya escribí en Context Drift. Aquel es temporal: la sesión se alarga, el historial se pudre, el agente se olvida de la instrucción de la iteración 3. Este es espacial. Se degrada en el minuto uno, con la ventana medio vacía, porque el repo es grande y nadie le ha dicho qué parte del repo importa.

    La tesis del post es esta: en un monorepo, la decisión más importante que tomas no es qué prompt escribes. Es desde qué directorio arrancas el agente.

    Smart context slicing es la práctica de arrancar el agente en el subárbol mínimo del monorepo que la tarea necesita, calculado a partir del grafo de dependencias en vez de a ojo. Son tres decisiones concretas: desde qué directorio lanzas claude, qué paquetes vecinos añades con --add-dir y qué rutas bloqueas con reglas de denegación. Las tres, en ese orden, son el resto del post.

    Claude Code en monorepos: un CLAUDE.md en la raíz no escala

    La documentación de Anthropic recomienda mantener cada CLAUDE.md por debajo de 200 líneas, y lo justifica: los archivos largos consumen más contexto y reducen la adherencia a las instrucciones.

    Ahora divide. Nueve paquetes, 200 líneas: 22 líneas por paquete para explicar su stack, sus convenciones y sus trampas.

    Así que solo hay dos finales, y he visto los dos.

    O el CLAUDE.md crece hasta las 600 líneas y el agente ignora la mitad — incluidas las reglas que importaban. O se queda genérico ("usa TypeScript estricto", "escribe tests"), que es una forma elegante de no decir nada.

    Si todavía estás montando el tuyo, el punto de partida lo dejé en CLAUDE.md: el system prompt de tu proyecto. Aquí doy por hecho que ya lo tienes y se te ha quedado pequeño.

    La solución es partirlo: raíz para lo global, un archivo por paquete para lo local.

    monorepo/
      CLAUDE.md                 # reglas globales: commits, estilo, "corre los scripts desde el paquete"
      packages/
        ui/CLAUDE.md            # convenciones de componentes, tokens de diseño
        api/CLAUDE.md           # Knex, migraciones, .env obligatorio
        web/CLAUDE.md           # rutas, data fetching
    

    Pero partirlo no sirve de nada si no entiendes cuándo se carga cada trozo.

    La regla de carga que casi nadie ha leído

    Claude Code no trata igual a los CLAUDE.md que están por encima de ti y a los que están por debajo.

    Dónde vive el CLAUDE.md Cuándo entra en contexto
    Tu directorio de trabajo y todos sus ancestros Al arrancar la sesión, siempre
    Subdirectorios por debajo de ti Bajo demanda, solo cuando el agente lee un archivo de esa carpeta

    Si arrancas desde la raíz, cargas solo el CLAUDE.md raíz — y vas acumulando el de cada paquete que el agente toque. Toca muchos, porque no sabe dónde está el límite.

    Si arrancas con cd packages/ui && claude, cargas raíz + packages/ui de golpe, y los de api y web no existen para esa sesión mientras no los pises. Además, solo puede leer y editar dentro de ese subárbol hasta que le concedas más.

    Eso es una rebanada. Y te ha costado un cd.

    Compruébalo: lanza /context y mira la lista de Memory files. Ahí está lo que se cargó de verdad.

    El slice no lo decides tú: lo decide el grafo de dependencias

    "Trabaja desde el paquete" está bien hasta que la tarea toca de verdad a los vecinos. Cambiar un tipo exportado de ui puede romper a quien lo consume, y si el agente no ve a esos consumidores, te entrega algo que compila en su rebanada y revienta en CI.

    La pregunta correcta no es qué paquetes te apetece abrir, sino qué paquetes toca esta tarea de verdad. Y esa respuesta ya está en tu repo: en el grafo de dependencias.

    Monté un workspace de cinco paquetes para verlo, con pnpm 11.1.3 y Turborepo 2.10.12. @acme/api y @acme/web dependen de @acme/ui; @acme/ui depende de @acme/config; @acme/jobs va por libre.

    Inventario primero:

    pnpm ls -r --depth -1
    

    Ahora el blast radius hacia arriba — qué se rompe si toco @acme/ui. En la sintaxis de filtros de pnpm, los tres puntos delante del nombre significan "y todo lo que depende de él":

    pnpm --filter "...@acme/ui" ls --depth -1
    # (salida recortada al nombre de cada paquete)
    # @acme/ui
    # @acme/api
    # @acme/web
    

    Y hacia abajo, con los puntos detrás, "y todo aquello de lo que depende":

    pnpm --filter "@acme/ui..." ls --depth -1
    # (salida recortada)
    # @acme/ui
    # @acme/config
    

    Si quieres el cierre completo en los dos sentidos, pones los puntos a ambos lados: "...@acme/ui...". Y si te sobra el propio paquete, el circunflejo lo excluye: "...^@acme/ui" devuelve solo api y web.

    Turborepo lo da con un matiz. --dry enseña el plan sin ejecutar nada:

    turbo run build --filter="...@acme/ui" --dry
    
    • Packages in scope: @acme/api, @acme/ui, @acme/web
    • Running build in 3 packages
    

    El detalle que solo ves ejecutándolo: "Packages in scope" son 3, pero si sacas el JSON aparecen 4 tareas:

    turbo run build --filter="...@acme/ui" --dry=json | jq -r '.tasks[].directory' | sort -u
    # packages/api
    # packages/config
    # packages/ui
    # packages/web
    

    @acme/config no está en el scope de edición, pero entra en el grafo de build porque ui lo necesita compilado. Son dos rebanadas distintas y conviene no confundirlas:

    Rebanada Paquetes % del repo
    Repo completo 5 100%
    Slice de edición (ui + dependientes) 3 60%
    Slice de build (añade config) 4 80%
    Nunca entra (@acme/jobs) 1 20%

    En un repo de cinco paquetes, dejar fuera un paquete suena a poco. En el del cliente, con nueve, el slice real de la tarea eran tres paquetes: dos tercios del repo que no tenían por qué abrirse nunca.

    Con esa lista en la mano, el arranque deja de ser una corazonada:

    cd packages/ui
    claude --add-dir ../api --add-dir ../web
    

    Si el equipo entero trabaja así, lo fijas en packages/ui/.claude/settings.json:

    {
      "permissions": {
        "additionalDirectories": ["../api", "../web"]
      }
    }
    

    Ojo con una diferencia que muerde: additionalDirectories da acceso a los ficheros pero no carga nunca el CLAUDE.md ni las skills de esos directorios. Con --add-dir sí cargan las skills, y el CLAUDE.md solo si arrancas con CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1. Si escribiste un CLAUDE.md en packages/api y no sale en /context, es por esto.

    Y como el slice te dice qué se puede romper, también sabes qué verificar antes de dar la tarea por buena: los tests de api y web, no los de ui. Convertir "parece que funciona" en un veredicto ejecutable lo desarrollé entero en el ebook gratuito Revisión por Contrato, sobre cómo revisar lo que te entrega un agente sin leértelo línea a línea.

    Lo que no debe entrar en la ventana bajo ningún concepto

    Las búsquedas de contenido de Claude Code respetan tu .gitignore por defecto, así que node_modules/, dist/ y build/ ya están fuera de los resultados de un grep.

    El problema es lo que sí está commiteado: código generado, un SDK vendorizado, snapshots enormes. Para eso hay reglas de denegación:

    {
      "permissions": {
        "deny": [
          "Read(./**/dist/**)",
          "Read(./**/*.generated.*)",
          "Read(./vendor/**)"
        ]
      }
    }
    

    Un detalle que rompe esto sin avisar: los patrones relativos anclan en el directorio desde el que arrancas la sesión, no en la raíz del repo. Si guardas estas reglas en la raíz pero lanzas la sesión desde packages/ui, Read(./vendor/**) está apuntando a packages/ui/vendor/. Para que apliquen en todo el repo las escribes absolutas, con doble barra: Read(//ruta/absoluta/al/repo/vendor/**).

    Y si arrancando desde la raíz se te cuelan los CLAUDE.md de equipos con los que no trabajas, existe claudeMdExcludes, en el .claude/settings.local.json de la raíz. Los patrones se comparan contra rutas absolutas, así que empiezan por **/ para que casen en cualquier punto del árbol:

    {
      "claudeMdExcludes": ["**/packages/legacy-*/**"]
    }
    

    Con un aviso honesto: esa lista es estática, no un interruptor por tarea. Para alternar de paquete cada día la herramienta sigue siendo el cd.

    Cuando de verdad no sabes dónde está, delega la búsqueda

    Todo lo anterior asume que sabes qué paquete tocar. A veces no lo sabes, y ahí es donde la gente destroza la sesión: "busca en el repo dónde se genera el token de refresco". El agente lee doscientos archivos y te devuelve una frase. Los doscientos archivos se quedan en tu ventana. La frase también, pero ya da igual.

    Delégalo a un subagente. Corre en su propia ventana de contexto y te devuelve el resumen, no los archivos:

    Usa un subagente para localizar en qué paquetes se genera y se valida
    el token de refresco. Devuélveme solo la lista de rutas y una línea
    por cada una. No propongas cambios todavía.
    

    El resultado es una lista de paquetes. Cierras la sesión, haces cd al correcto y empiezas la tarea real con la ventana limpia. La exploración se paga una vez y se tira.

    Es el mismo principio que conté en Context Engineering: lo caro no es el token, es el token irrelevante que se queda mirándote el resto de la sesión.

    Lo que puedes hacer hoy en tu monorepo

    Una sola cosa, y es gratis: deja de arrancar el agente desde la raíz del monorepo.

    Antes de la próxima tarea, corre pnpm --filter "...<tu-paquete>" ls --depth -1, mira los tres o cuatro nombres que salen, y arranca así:

    cd packages/<tu-paquete>
    claude --add-dir ../<vecino>
    

    No hace falta que escribas ni un CLAUDE.md nuevo para notar la diferencia. Eso viene después, cuando ya sepas qué reglas son globales y cuáles de un paquete — y eso solo se ve claro tras unos días trabajando por rebanadas.

    Si quieres el flujo completo, de la idea al producto con estas decisiones tomadas antes de escribir código, es lo que montamos en el curso Construye con IA.

    Preguntas frecuentes

    ¿Es mejor arrancar Claude Code desde la raíz del monorepo o desde el paquete?

    Desde el paquete, salvo que la tarea cruce varios subsistemas de verdad. Arrancando desde packages/ui cargas el CLAUDE.md raíz más el de ui, y el agente solo puede leer y editar ese subárbol. Desde la raíz tienes acceso a todo: útil para refactors transversales, caro para cualquier otra cosa. Si necesitas un vecino puntual, --add-dir te lo añade sin romper el aislamiento.

    ¿Los CLAUDE.md de los subdirectorios se cargan siempre?

    No, y esta es la confusión más habitual. Los de tu directorio de trabajo y de todos sus ancestros se cargan al arrancar la sesión. Los de subdirectorios por debajo de ti se cargan bajo demanda, solo cuando el agente lee un archivo de esa carpeta. Para ver qué se cargó de verdad en una sesión, lanza /context.

    ¿Qué hago si la tarea toca varios paquetes a la vez?

    Dásela entera en una sola sesión, con el slice completo delante. Partirla en una sesión por paquete es peor: cada sesión redecide el diseño desde cero y acabas con tres criterios distintos. Calcula el slice con el filtro de dependientes, añade esos directorios y trabaja en plan mode antes de editar: el plan se escribe a un archivo que Claude Code reinyecta tras cada compactación.

    ¿Esto sirve si uso Nx o si mi repo es un solo árbol grande sin paquetes?

    Sí. En Nx el equivalente es nx graph para ver el grafo y nx show projects --affected para saber qué proyectos toca un cambio: cambia el comando, no la idea. Y en un repo de un solo árbol sustituyes "paquete" por "subsistema" — src/billing/, src/auth/, lib/core/. Un CLAUDE.md por subsistema y un cd hacen el mismo trabajo.

    ¿No basta con el .gitignore para que el agente no lea dist?

    Para las búsquedas de contenido sí: Claude Code respeta el .gitignore por defecto, así que dist/, build/ y node_modules/ no aparecen cuando busca texto. Lo que no cubre es lo commiteado — código generado, SDKs vendorizados, fixtures gigantes. Para eso necesitas reglas Read(...) en permissions.deny. Con un límite: cubren las herramientas de fichero y los comandos de Bash que Claude Code reconoce, pero un grep -r sobre una carpeta con ficheros denegados sigue sacándolos por pantalla.


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

  • Tu agente no sale del repo: interoperabilidad de agentes de IA

    Tu agente no sale del repo: interoperabilidad de agentes de IA

    Escribí un subagente de revisión de código para Claude Code. Lee el diff, comprueba el contrato del módulo y marca lo que rompe.

    Un equipo con el que trabajo quiso ese mismo criterio en su pipeline, que no corre sobre Claude Code. Abrí el fichero para copiarlo y la ilusión me duró treinta segundos.

    Lo único portable era el criterio, y el criterio son cuatro párrafos de texto. El resto —cómo pide las herramientas, dónde guarda lo revisado, quién arranca el bucle, cómo reporta— estaba pegado al harness.

    Ese es el estado real de la interoperabilidad de agentes de IA hoy: no existe. Tenemos agentes que funcionan muy bien exactamente donde nacieron y en ningún otro sitio.

    La interoperabilidad de agentes de IA es la capacidad de ejecutar el mismo agente —su criterio, sus herramientas, su memoria y su bucle— en un harness distinto de aquel donde se escribió, sin reescribirlo. No consiste en que hable con otros agentes: consiste en que se mude.


    El software se volvió reutilizable. Los agentes, no

    El software se convirtió en una industria enorme por una razón aburrida: se escribe una vez y se usa muchas.

    Una librería la escribe un dev y la usan miles. Una API expone una capacidad y acaba dentro de productos que su autor nunca vio. Las app stores añadieron distribución global a eso. Cada pieza de software podía ser el bloque de construcción de otra cosa.

    Un agente debería llevar esa idea más lejos, no menos. No expone una función: expone un criterio. Entiende un objetivo, decide, usa herramientas, se comunica y ejecuta trabajo. Un buen agente de revisión, de extracción de facturas o de migración de tests debería ser un trabajador digital que enchufas donde haga falta su capacidad.

    Y sin embargo. El agente de extracción de facturas que montó tu compañero con LangChain no puede entrar en el CLI del equipo de al lado. El agente de tests que va fino en tu runtime se rompe entero en otro. No porque el criterio sea malo: porque el criterio nunca aprendió a viajar solo.

    Eso tiene tres consecuencias que ya estamos pagando.

    La primera es que cada equipo reconstruye lo mismo. Miles de empresas escribiendo su propio agente de research, su propio agente de soporte, su propio agente de procesamiento de documentos. El mismo trabajo de ingeniería repetido porque ninguno de esos agentes se mueve de su proyecto.

    La segunda es que impide la especialización. Nadie puede dedicar dos años a construir el mejor agente de auditoría de accesibilidad del mundo y distribuirlo por muchos sistemas. Cada agente se trata como un detalle de implementación interno, no como un producto.

    Y la tercera: sin portabilidad no hay mercado. No puede existir un marketplace real si un agente solo funciona dentro del harness donde nació, ni efecto red si añadirlo beneficia a una sola aplicación.


    El acoplamiento no está donde crees

    Cuando alguien dice "muevo mi agente a otro entorno" suele pensar en copiar el prompt. El prompt es lo barato. Lo caro es todo lo que el harness le daba gratis.

    Capa Qué cambia al mover el agente
    Tool calling El esquema de las tools, sus nombres, cómo se serializan los resultados
    Contexto y memoria Qué entra en la ventana, qué se resume, dónde persiste entre turnos
    Bucle de ejecución Quién decide cuándo parar, cuántos pasos caben, quién reintenta
    Transporte stdio, HTTP con streaming, cola de mensajes
    Permisos Quién aprueba una escritura y con qué granularidad
    Reporte de progreso Logs sueltos, eventos tipados, estados de tarea

    Copiar el prompt y creer que has movido el agente es como copiar un componente de React sin llevarte el router, los tipos ni el ciclo de vida. Tienes el texto. No tienes el comportamiento.

    Por eso insisto tanto en que el harness es la pieza que de verdad define a un agente. El modelo es intercambiable. El harness, hoy, no.


    Qué resuelven MCP y A2A de la interoperabilidad de agentes de IA (y qué no)

    MCP y A2A resuelven dos capas del problema: las herramientas y la comunicación entre agentes. Ninguno de los dos toca el runtime, el contexto ni el bucle, que es donde vive el acoplamiento real. Son dos intentos serios de estandarizar esto y conviene ser honesto con el alcance de cada uno.

    MCP estandariza la capa de herramientas y el contexto que se sirve. En su revisión 2026-07-28, un servidor expone tres primitivas —tools, resources y prompts— sobre JSON-RPC 2.0, y cualquier cliente las descubre e invoca igual. Eso arregla la primera fila de la tabla y parte del transporte, y no es poco: el mismo servidor vale para clientes distintos. Si nunca has montado uno, empieza por qué es MCP exactamente.

    Lo que MCP no define es el comportamiento del agente: qué entra en su ventana de contexto, quién arranca su bucle o cuándo decide parar. Los permisos ni siquiera intenta cubrirlos —la propia spec reconoce que "MCP itself cannot enforce these security principles at the protocol level" y los delega en el host. La revisión actual se acerca por los bordes, eso sí: la extensión Tasks cubre operaciones largas con polling y handles duraderos, y el grupo de trabajo Skills over MCP quiere distribuir instrucciones de agente como recurso. Ninguna de las dos, todavía, te deja mover un agente de harness.

    A2A estandariza el intercambio entre agentes. La versión 1.0.0 define la Agent Card para descubrir capacidades, las Tasks con su ciclo de vida de ocho estados (submitted, working, input-required, auth-required, completed, failed, canceled, rejected), los Messages y los Artifacts. Eso arregla la fila del reporte y buena parte de la comunicación.

    Y el límite lo pone la especificación misma, por escrito: los agentes colaboran "without needing to share their internal thoughts, plans, or tool implementations". Ahí está la frontera, literal. A2A te deja hablar con un agente remoto; no te deja traerte ese agente a casa.

    Puestos capa por capa contra la tabla de antes, el reparto queda así:

    Capa de acoplamiento MCP 2026-07-28 A2A 1.0.0 Quién la resuelve hoy
    Tool calling Sí — MCP
    Transporte Parcial (JSON-RPC 2.0) Parcial MCP / A2A
    Reporte de progreso Parcial Sí (ciclo de vida de Task) A2A
    Contexto y memoria Parcial (resources) No Casi nadie — tu harness
    Bucle de ejecución No No Nadie — tu harness
    Permisos No (delega en el host) No Nadie — tu harness

    Los dos juntos te dan el cableado. Ninguno te da el agente portable. Si quieres la comparativa fila a fila de A2A y MCP, la tienes desarrollada en su propio post.

    Mi tesis es incómoda pero creo que es la correcta: el agente reutilizable de verdad todavía no existe, y la frontera no la marca el protocolo sino el harness. Lo que sí podemos hacer hoy es diseñar como si esa capa ya estuviera, para no tener que rehacerlo cuando llegue.


    Los tres pilares de la interoperabilidad de agentes de IA

    Un agente portable necesita tres propiedades arquitectónicas: concurrencia (se activa por eventos, no por su posición en una cadena), awareness o conciencia del entorno (lo consulta en vez de suponerlo) y adaptividad (decide con estado de runtime, no con un orden hardcodeado).

    Compartir un agente es más que mover su código. Un agente que aterriza en un entorno nuevo tiene que poder trabajar sin esperar a una secuencia predefinida, entender qué hay a su alrededor y ajustar su comportamiento a lo que encuentra.

    1. Concurrencia: fuera los pipelines secuenciales

    Casi todos los sistemas multiagente que reviso son esto:

    // Acoplado: el paso 3 no existe hasta que termina el 2.
    const spec = await specAgent.run(input);
    const code = await codeAgent.run(spec);
    const review = await reviewAgent.run(code);
    

    Esto no es un sistema de agentes. Es una función con tres llamadas caras. Que use await no lo salva: el orden está hardcodeado en el código que las invoca, así que el agente de revisión no puede existir fuera de ese fichero. Es el mismo error de fondo que hace fallar al mega-prompt cuando el sistema crece.

    La alternativa es que cada agente sea una unidad independiente que decide si un evento le incumbe:

    interface Agent {
      readonly id: string;
      readonly capabilities: readonly string[];
      // ¿Este evento va conmigo?
      accepts(event: AgentEvent): boolean;
      handle(event: AgentEvent, ctx: RuntimeContext): Promise<AgentEvent[]>;
    }
    

    Ningún agente bloquea a otro. Ninguno conoce su posición en una cadena. Cuando esto está bien hecho, a menudo descubres que no necesitas orquestador.

    2. Awareness: el entorno se consulta, no se supone

    Un agente acoplado solo conoce su prompt. Lo que hay alrededor está implícito en el orden de las llamadas.

    Un agente portable pregunta. Necesita dos cosas: un canal de eventos compartido y un registro de participantes.

    type Unsubscribe = () => void;
    
    type AgentEventType =
      | 'spec.ready'
      | 'code.changed'
      | 'review.blocked'
      | 'test.requested';
    
    interface AgentEvent {
      readonly type: AgentEventType;
      readonly source: string; // id del agente que lo emitió
      readonly payload: unknown;
      readonly at: number;
    }
    
    interface AgentDescriptor {
      readonly id: string;
      readonly capabilities: readonly string[];
    }
    
    interface Workspace {
      // Quién más está trabajando aquí y qué sabe hacer.
      participants(): readonly AgentDescriptor[];
      publish(event: AgentEvent): void;
      subscribe(handler: (event: AgentEvent) => void): Unsubscribe;
    }
    
    interface RuntimeContext {
      // Todo lo que el agente necesita del entorno donde aterriza.
      readonly workspace: Workspace;
    }
    

    La diferencia práctica: con esto, el mismo agente de revisión funciona en un entorno donde hay tres compañeros y en otro donde está solo, porque en el primer caso lo sabe. Monté el patrón completo en event bus para agentes descentralizados.

    3. Adaptividad: la decisión sale del estado, no del orden

    El tercer pilar es el que casi nadie implementa, y es el que separa un agente de un script con LLM dentro.

    async function handle(
      event: AgentEvent,
      ctx: RuntimeContext,
    ): Promise<AgentEvent[]> {
      const emit = (type: AgentEventType, payload: unknown): AgentEvent => ({
        type,
        source: 'reviewer',
        payload,
        at: Date.now(),
      });
    
      const findings = await runReview(event.payload, ctx);
      if (findings.length === 0) return [];
    
      // Si hay alguien capaz de ejecutar tests, delego. Si no, bloqueo.
      const peers = ctx.workspace.participants();
      const hasTester = peers.some((p) => p.capabilities.includes('test.run'));
    
      return hasTester
        ? [emit('test.requested', { findings })]
        : [emit('review.blocked', { findings })];
    }
    

    Fíjate en lo que no hay: ningún if (step === 'review'). La rama se decide con estado de runtime, no con una posición hardcodeada. Ese agente se comporta distinto en dos entornos distintos sin que nadie toque su código.

    Las tres juntas son las caras del mismo triángulo: independencia, conexión, colaboración. Si te falta una, el agente no viaja.

    Esta forma de pensar el sistema —el agente como unidad con contrato propio, no como paso de un flujo— es la que trabajo en el curso Construye con IA: de la idea al producto con Claude Code.


    Lo que se desbloquea cuando los agentes viajan

    Construyes un agente una vez y lo distribuyes en todas partes. Combinas especialistas en lugar de reconstruirlos. Y puedes monetizar una capacidad sin vender la aplicación entera alrededor.

    El cambio de fondo es de economía, no de ingeniería. En un ecosistema interoperable, cada agente nuevo aumenta el valor de todos los demás. Hoy cada agente nuevo aumenta el valor de exactamente un repositorio.


    Cómo diseñar hoy un agente portable en tu proyecto

    No hace falta esperar a que se asiente ningún estándar. Cinco decisiones que puedes tomar esta semana:

    1. Separa el agente de su runtime. El agente es un objeto con capacidades declaradas y un handle. Quién lo arranca y cada cuánto es responsabilidad de otro fichero.
    2. Expón sus herramientas vía MCP, aunque hoy solo lo use tu propio harness. Es la capa que ya está estandarizada; aprovéchala.
    3. Saca el contexto del prompt. Ficheros, un store, lo que sea. Si la memoria del agente vive en la cadena de mensajes de tu framework, tu agente es tu framework.
    4. No hardcodees la secuencia. Sustituye await a(); await b(); por eventos tipados. Si te cuesta imaginarlo, empieza por construir un agente de IA desde cero y verás dónde está cada costura.
    5. Escribe el contrato antes que el código. Qué acepta, qué emite, qué permisos pide, qué garantiza. Es revisión por contrato aplicada al diseño, y el mismo principio que desarrollo en Spec-Driven Development: la especificación es la parte portable; la implementación es desechable.

    Si el punto 5 te suena a burocracia, empieza por el ebook gratuito Revisión por Contrato. Va justo de eso: definir por escrito qué puede y qué no puede hacer un agente antes de dejarlo suelto en tu repo.

    En Dominicode Labs están las masterclasses y los repos donde desmonto este tipo de decisiones de arquitectura con el código delante y sin diapositivas.

    Elige hoy uno de tus agentes y responde a una sola pregunta: si mañana cambias de harness, ¿qué sobrevive? Si la respuesta es "el prompt", ya sabes por dónde empezar.


    Preguntas frecuentes

    ¿MCP no resuelve ya la interoperabilidad de agentes de IA?

    Resuelve una parte importante, no el conjunto. MCP estandariza cómo un agente descubre e invoca herramientas y cómo un servidor le sirve contexto como recurso, así que el mismo servidor vale para clientes distintos y eso elimina una de las seis capas de acoplamiento. Hay trabajo en curso para llevarlo más lejos —la extensión Tasks y el grupo de Skills over MCP—, pero a día de hoy nada de eso está cerrado. Pero un agente no es solo el conjunto de herramientas que puede llamar: es también su bucle, su gestión de contexto, su política de permisos y su forma de reportar. Nada de eso está cubierto. Puedes tener dos agentes que hablan MCP perfectamente y seguir sin poder mover ninguno de los dos al entorno del otro.

    Entonces, ¿A2A sobra?

    Al contrario: resuelve un problema distinto y complementario. A2A estandariza el intercambio entre agentes —descubrimiento de capacidades, envío de tareas, mensajes y progreso— y con eso puedes hacer que tu sistema hable con un agente que corre en otra empresa. Lo que no te da es portabilidad: sigues invocando un agente remoto que vive en su propio runtime. MCP y A2A son cableado en dos capas diferentes. El agente portable es otra discusión.

    ¿Concurrencia no es simplemente lanzar todo con Promise.all?

    No. Promise.all lanza varias llamadas a la vez, pero el punto donde se lanzan y el punto donde se espera siguen escritos en tu código: tú decides qué va junto y dónde se bloquea. Lo que pido aquí es otra cosa, desacoplamiento temporal: cada agente se activa por su cuenta cuando aparece un evento que le incumbe, sin que nadie coordine el orden desde fuera. La prueba está en si puedes añadir un agente nuevo al sistema sin tocar el fichero que orquesta. Si tienes que tocarlo, tienes llamadas concurrentes, no agentes autónomos.

    ¿No es sobreingeniería para un agente que solo uso yo?

    Depende de cuánto te haya costado ese agente. Si es un script de veinte líneas, sí, es sobreingeniería. Si le has dedicado semanas a afinar su criterio —y en revisión de código o extracción de datos eso pasa rápido— entonces lo que estás haciendo al acoplarlo es tirar ese trabajo cada vez que cambies de herramienta. Y cambiamos de herramienta cada pocos meses. En mi experiencia, separar el agente de su runtime cuesta una tarde; reescribirlo entero, varias semanas.

    Mi agente ya está acoplado al harness. ¿Por dónde empiezo?

    Por el contexto, que suele ser lo más doloroso y lo que antes se rompe. Saca de la cadena de mensajes del framework todo lo que sea conocimiento del agente y llévalo a ficheros o a un store propio. Después extrae la lógica de decisión a una función pura que recibe estado y devuelve eventos. Cuando tengas esas dos piezas, el runtime original pasa a ser un adaptador fino de treinta líneas, y escribir un segundo adaptador para otro entorno deja de dar miedo.


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

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