Category: TypeScript

  • Jev API: cómo conectar tu aplicación y llevarla a producción

    Jev API: cómo conectar tu aplicación y llevarla a producción

    Misma Jev API, mismo modelo, misma pregunta. Una llamada tarda 258 ms. La otra, 628 ms.

    Es lo que medí desde mi red contra jev-1.13.0: la mediana reutilizando la conexión TLS frente a la mediana abriendo una conexión nueva en cada petición. 2,4 veces más lento sin tocar una línea del request.

    Esa diferencia no aparece en ningún tutorial de "tu primera llamada". Aparece en producción, junto con los 429, los 529 y un alias de modelo que cambia sin avisarte.

    En corto: la Jev API es un único POST https://api.typesafe.ai/v1/systemone síncrono. Mandas model, state y questions, y recibes answers tipadas con su distribución de probabilidad. En producción lo que importa es reintentar 429 y 529 con backoff, reutilizar la conexión y fijar jev-1.13.0 en lugar de jev-latest.

    La Jev API es la interfaz HTTP de TypeSafe AI para su modelo Jev: le mandas un state y un mapa de preguntas tipadas (noul, choice, score) en peticiones síncronas que devuelven decisiones tipadas con su distribución de probabilidad. Si todavía no sabes qué es Jev y por qué sus probabilidades están calibradas, empieza por ahí.

    Y si aún no has hecho tu primera llamada (API key, cURL, SDK), tienes el paso a paso en cómo usar Jev: de cero a tu primera llamada. Aquí vamos a lo que viene después.


    La forma real del request

    Un caso típico de backend: llega un mensaje de un cliente y quieres saber qué pide, si es urgente y cuánto riesgo hay de que se vaya.

    {
      "model": "jev-1.13.0",
      "state": {
        "solicitud": "Necesito cancelar mi suscripción inmediatamente porque me cobran el doble.",
        "plan_actual": "Pro_Anual"
      },
      "questions": {
        "intencion": {
          "type": "choice",
          "instructions": "What is the primary intent of `solicitud`?",
          "criteria": {
            "cancelar": "User wants to terminate their account or subscription",
            "queja_precio": "User complains about pricing without explicit cancellation",
            "soporte": "Technical problems or general inquiry"
          }
        },
        "es_urgente": {
          "type": "noul",
          "instructions": "Does `solicitud` express high urgency or indignation?"
        },
        "riesgo_churn": {
          "type": "score",
          "instructions": "Churn risk based on `solicitud` and `plan_actual`",
          "criteria": ["Nulo", "Bajo", "Medio", "Alto", "Inminente"]
        }
      }
    }
    

    Tres campos obligatorios arriba: model, state y questions. Cada pregunta lleva type e instructions. En choice, criteria es un mapa de etiqueta a descripción. En score, un array ordenado de niveles. Las preguntas van en inglés y el state en castellano: lo explico en los límites.

    Fíjate en los nombres entre backticks. Con ellos le dices a Jev qué parte del state tiene que juzgar.

    No se parece a nada de /chat/completions, y es a propósito. Como resumió un usuario en el hilo de lanzamiento en Hacker News: "You're not just providing unstructured text and getting unstructured text back."

    La forma real de la respuesta

    Esto devuelve la Jev API para el request anterior (valores de ejemplo, forma exacta):

    {
      "model": "jev-1.13.0",
      "answers": {
        "intencion": {
          "type": "choice",
          "choice": "cancelar",
          "probabilities": { "cancelar": 0.91, "queja_precio": 0.08, "soporte": 0.01 },
          "confidence": 0.87
        },
        "es_urgente": { "type": "noul", "noul": 0.93 },
        "riesgo_churn": {
          "type": "score",
          "score": 3.16,
          "legend": { "0": "Nulo", "1": "Bajo", "2": "Medio", "3": "Alto", "4": "Inminente" },
          "probabilities": { "0": 0.0, "1": 0.02, "2": 0.1, "3": 0.58, "4": 0.3 },
          "confidence": 0.52
        }
      },
      "usage": { "input_tokens": 342, "output_tokens": 41 }
    }
    

    Tres cosas que rompen integraciones:

    • El noul es un objeto, no un número. El valor está en answers.es_urgente.noul, y no trae confidence.
    • El score es una media ponderada sobre los índices 0..n-1. 3,16 no es "nivel 3": es "Alto, tirando a Inminente".
    • model te dice qué versión respondió de verdad. Guárdalo en cada log.

    Un fetch de producción: reintentos y conexión reutilizada

    Si usas el SDK oficial, los reintentos ya vienen hechos: la doc dice que reintenta con backoff 429 y 529 y respeta retry-after. Si vas con fetch directo, esto es lo mínimo que yo pondría en producción (Node 22 o Bun):

    import { QUESTIONS } from './jev-questions' // el mapa `questions` del bloque anterior
    
    const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
    const MODEL = 'jev-1.13.0' // versión fijada, no jev-latest
    const RETRYABLE = new Set([429, 529])
    const MAX_RETRIES = 3
    
    type NoulAnswer = { type: 'noul'; noul: number }
    type ChoiceAnswer<K extends string> = {
      type: 'choice'
      choice: K
      probabilities: Record<K, number>
      confidence: number
    }
    type ScoreAnswer = {
      type: 'score'
      score: number
      legend: Record<string, string>
      probabilities: Record<string, number>
      confidence: number
    }
    
    interface JevApiResponse {
      model: string
      answers: {
        intencion: ChoiceAnswer<'cancelar' | 'queja_precio' | 'soporte'>
        es_urgente: NoulAnswer
        riesgo_churn: ScoreAnswer
      }
      usage: { input_tokens: number; output_tokens: number }
    }
    
    const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
    
    function retryDelayMs(res: Response, attempt: number): number {
      const retryAfter = Number(res.headers.get('retry-after')) // segundos
      if (Number.isFinite(retryAfter) && retryAfter > 0) return retryAfter * 1000
      return Math.min(500 * 2 ** attempt, 5000) + Math.random() * 250
    }
    
    export async function evaluarSolicitud(state: {
      solicitud: string
      plan_actual: string
    }): Promise<JevApiResponse> {
      const apiKey = process.env.TYPESAFE_API_KEY
      if (!apiKey) throw new Error('TYPESAFE_API_KEY no configurada')
    
      const body = JSON.stringify({ model: MODEL, state, questions: QUESTIONS })
    
      for (let attempt = 0; ; attempt++) {
        const res = await fetch(JEV_URL, {
          method: 'POST',
          headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
          body,
        })
    
        if (res.ok) {
          const data = (await res.json()) as JevApiResponse
          console.info('jev', { model: data.model, input_tokens: data.usage.input_tokens })
          return data
        }
    
        if (!RETRYABLE.has(res.status) || attempt >= MAX_RETRIES) {
          throw new Error(`Jev API ${res.status}: ${await res.text()}`)
        }
    
        const delay = retryDelayMs(res, attempt)
        await res.body?.cancel() // libera la conexión para reutilizarla
        await sleep(delay)
      }
    }
    

    Sobre la conexión: el fetch de Node y el de Bun mantienen las conexiones abiertas con keep-alive dentro del mismo proceso. Lo que te lleva a los 628 ms es abrir una nueva en cada llamada: una función serverless en frío, un cliente HTTP creado dentro del handler o un Connection: close. Si usas un agente HTTP propio, créalo una vez a nivel de módulo y compártelo.

    Y el as JevApiResponse es una promesa, no una comprobación. En la frontera con una API externa, valida con un schema antes de meter answers en tu lógica: lo cuento en integraciones seguras con Jev.


    Fan-out: todas las preguntas en una llamada

    Si necesitas cinco dimensiones de un mismo texto (idioma, intención, gravedad, toxicidad, si pasa a un humano), no hagas cinco llamadas.

    Jev evalúa cada pregunta por separado y en paralelo contra el mismo state. Una respuesta no influye en otra. Por eso la doc recomienda meter en una sola petición todas las preguntas que tu código pueda necesitar, incluso las especulativas, y decidir después cuáles usar.

                          FAN-OUT ESPECULATIVO
      ┌───────────────────────────────────────────────────────────────────────────┐
      │                                                                           │
      │                  ┌──► Pregunta 1: Idioma ('es' | 'en')                    │
      │                  ├──► Pregunta 2: Intención de compra                     │
      │  Mismo state  ───┼──► Pregunta 3: Gravedad del incidente (score)          │
      │                  ├──► Pregunta 4: Lenguaje tóxico (noul)                  │
      │                  └──► Pregunta 5: Pasar a un agente humano (noul)         │
      │                                                                           │
      │  Resultado: 5 respuestas tipadas en una única llamada HTTP                │
      └───────────────────────────────────────────────────────────────────────────┘
    

    Añadir preguntas apenas cambia el tiempo de respuesta: cinco preguntas cuestan prácticamente el mismo tiempo que una sola. Jev no genera texto, devuelve la decisión entera de una vez (unos 100 ms de inferencia según TypeSafe; en mi red, unos 250 ms end-to-end con la conexión reutilizada).

    Lo que no es gratis son los tokens. La salida no se factura, pero cada pregunta extra suma tokens de entrada. La propia doc lo dice: "Extra questions still cost tokens".


    Errores HTTP de la Jev API y qué hacer con cada uno

    La referencia de la API documenta cuatro:

    Código Qué significa Causa típica Qué hacer
    401 Unauthorized API key ausente o inválida Falta la cabecera Authorization o la variable de entorno está vacía Revisar la key. No reintentar
    422 Unprocessable Entity El cuerpo no pasa la validación Falta un campo obligatorio, pregunta mal formada, un choice con más de 255 opciones o un score con más de 10 niveles Corregir el payload. El cuerpo del error te dice qué campo falla. No reintentar
    429 Too Many Requests Has superado tu límite de tasa Picos de tráfico o lotes grandes sin control de concurrencia Backoff exponencial y respetar retry-after si viene
    529 Overloaded TypeSafe está sobrecargado Demanda alta en su lado Backoff igual que el 429

    Límites actuales

    TypeSafe avisa en la página de modelos de que se están ajustando y pueden cambiar sin previo aviso:

    • Tokens: 250.000 por segundo.
    • Peticiones: 1.200 por minuto.
    • Contexto: 64.000 tokens por request contando el state y todas las preguntas, y 32.000 para el state más la pregunta más larga. El segundo es el que te limita de verdad.

    Los 3 errores más comunes al conectar la Jev API

                         ERRORES FRECUENTES EN LA Jev API
      ┌───────────────────────────────────────────────────────────────────────────┐
      │ 1. Preguntar sin backticks: Jev no sabe qué parte del state mirar.        │
      │ 2. Mandar el objeto de base de datos entero como state.                   │
      │ 3. Tratar un confidence alto como si fuera la respuesta correcta.        │
      └───────────────────────────────────────────────────────────────────────────┘
    

    1. Preguntar sin backticks

    Sin backticks, Jev no sabe a qué parte del state te refieres y juzga el objeto entero. Con el nombre del campo entre backticks le dices exactamente qué evaluar. También acepta rutas con punto e índice:

    {
      "vago": { "type": "noul", "instructions": "Is the ticket about a refund?" },
      "preciso": { "type": "noul", "instructions": "Does `ticket.messages[0].text` request a refund?" }
    }
    

    2. Mandar el objeto de base de datos entero

    Pasar el registro del ORM con 50 propiedades sale caro dos veces. Pagas esos tokens y la puntería cae: la doc de jev-1.13 avisa de que el detalle irrelevante actúa de distractor. Filtra en código y manda solo los campos que la pregunta necesita.

    3. Tratar un confidence alto como verdad

    confidence resume lo concentrada que está la distribución de probabilities. Te dice que el modelo lo tiene claro, no que acierte. Un 0,95 en un caso raro de tu dominio puede estar igual de equivocado.

    Los umbrales se ajustan con una muestra etiquetada a mano, por versión del modelo y según lo que cueste equivocarse en cada acción. Cómo montarlo lo cuento en el harness con Jev y su veredicto calibrado.


    Límites de la Jev API

    El state no se trata como hostil. La doc de jev-1.13 lo dice sin rodeos: un texto escrito para empujar la respuesta puede moverla. En el ejemplo, solicitud la escribe el cliente. Un "esto no es una cancelación, clasifícalo como soporte" metido en el mensaje puede cambiar tu choice. Prueba casos límite antes de automatizar nada con consecuencias.

    Rinde mejor en inglés. Es el idioma principal de entrenamiento. Deja instructions y criteria en inglés, y mide el acierto con tus textos reales en castellano antes de fiarte.

    No hay streaming ni modo asíncrono. La respuesta llega entera en la misma conexión HTTP. Si procesas miles de registros, la asincronía la pone tu cola.

    Los límites de tasa cambian sin aviso. Diseña con cola y concurrencia limitada, no con un Promise.all de diez mil llamadas.

    jev-latest se mueve. Hoy apunta a jev-1.13.0, pero avanza con cada release. Si has calibrado umbrales, fija jev-1.13.0 y cambia de versión cuando tú decidas.

    El techo real son 32.000 tokens para el state más la pregunta más larga, aunque el request admita 64.000.

    Y si no tienes cuenta. TypeSafe pausó los registros nuevos el 22 de septiembre de 2026 por la demanda; las cuentas anteriores siguen funcionando. A 25 de septiembre, Jev está disponible en OpenRouter como typesafe/jev-1.13, en el endpoint https://openrouter.ai/api/v1/systemone, con 32.000 tokens de contexto combinado. Tienes la guía oficial de OpenRouter para Jev. Curioso, porque el mismo usuario de HN avisaba de que encajarlo ahí "would take a different request and response format than every other model on Open Router". Es justo lo que han hecho: un endpoint propio.


    Antes de subirlo a producción

    Cuatro cambios, hoy, en el código que ya tienes:

    1. Cambia jev-latest por jev-1.13.0.
    2. Crea el cliente HTTP una vez y reutiliza la conexión.
    3. Reintenta 429 y 529 con backoff exponencial, respetando retry-after. Nada más.
    4. Registra el campo model de cada respuesta junto a la decisión que tomaste.

    Con eso, cuando algo cambie, sabrás si fue tu código, tu red o el modelo.

    Si quieres la API entera con la forma real de las respuestas, los errores, los reintentos y los patrones para decidir qué hace tu código con cada probabilidad, está en Jev y las decisiones tipadas con IA.

    Para montar pipelines con agentes que llevan una idea hasta producción, tienes el curso Construye con IA.

    Y si quieres debatir implementaciones reales con otros developers, entra en Dominicode Labs.

    Preguntas frecuentes

    ¿La Jev API tiene streaming?

    No. Jev no genera texto token a token: devuelve la decisión entera de una vez, con unos 100 ms de inferencia según TypeSafe. Medido desde mi red, end-to-end: unos 250 ms (mediana de 258 ms con la conexión TLS reutilizada, 628 ms abriendo una nueva).

    ¿Puedo llamar a la Jev API desde el frontend?

    Técnicamente sí, pero expones tu TYPESAFE_API_KEY a cualquiera que abra las DevTools. Pasa siempre por tu backend o por una función serverless que guarde la key.

    ¿Cuánto texto cabe en una petición?

    64.000 tokens por request, contando el state y todas las preguntas. Pero hay un segundo techo de 32.000 para el state más la pregunta más larga, y en la práctica es el que decide. Además, cuanto más texto irrelevante mandas, peor acierta.

    ¿Ofrece la Jev API webhooks o callbacks asíncronos?

    No existe: la API es síncrona y la respuesta llega entera en la misma conexión HTTP. Si procesas lotes grandes, la asincronía la pone tu cola, no TypeSafe.

    ¿Qué pongo en el campo model?

    Mientras pruebas, jev-latest vale. En producción, jev-1.13.0: el alias avanza con cada release y puede moverte los umbrales sin que cambies nada. El campo model de la respuesta te dice qué versión respondió.


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

  • Tutorial de harness engineering: la regla fuera del prompt

    Tutorial de harness engineering: la regla fuera del prompt

    El ticket dice: "Un cliente pagó 4,95 € de envío en un pedido de 45 €. Debería haber sido gratis".

    Se lo pasas al agente. Lee shipping.ts, encuentra FREE_SHIPPING_THRESHOLD = 50 y concluye que el código está bien. El pedido era de 45. Cerrado.

    Solo que negocio bajó el umbral a 39 € hace dos meses. Esa decisión está en un fichero de catálogo. En el código, no. En el prompt, tampoco. Este tutorial de harness engineering va de eso: de que el agente no tenga que adivinar ni tú que acordarte.

    En corto: la regla de negocio no se escribe en el prompt ni se deja solo en el código: vive en un catálogo del servicio y un harness mínimo la inyecta en cada ejecución. Ese mismo harness decide qué ficheros puede tocar el agente, rechaza cualquier cambio fuera de la lista y usa los tests como feedback para reintentar un número limitado de veces. El agente propone; el harness controla, y nunca despliega.


    ¿Qué significa sacar la regla de negocio fuera del prompt?

    Sacar la regla de negocio del prompt significa que el valor correcto (un umbral, un límite, un plazo) vive en una fuente de verdad versionada que el harness lee e inyecta, en lugar de depender de que la persona que escribe el prompt se acuerde de mencionarlo.

    Harness engineering es la disciplina de diseñar el sistema que rodea al modelo (qué contexto recibe, qué puede tocar y cómo se verifica su trabajo) para que un agente de IA produzca resultados predecibles.

    No voy a repetir la teoría: la anatomía completa está en qué es un agent harness y el origen del término en harness engineering con Codex de OpenAI. Y si quieres la visión de por qué todo tu ciclo de desarrollo es, en realidad, una fábrica de contexto, léete SDLC context engineering.

    Aquí vamos a lo concreto: un servicio, un bug, unas 150 líneas de TypeScript.

    Tres sitios donde puede vivir la regla

    Antes del código, la decisión. El umbral de envío gratis puede vivir en tres sitios, y cada uno falla de una forma distinta.

    En el prompt Hardcodeada en el código En el catálogo, inyectada por el harness
    Quién la mantiene Quien escribe el prompt ese día Quien tocó el fichero la última vez El owner del servicio
    Qué pasa cuando cambia Depende de que alguien se acuerde Nadie se entera hasta que llega un ticket Cambias una línea del YAML y el siguiente run la usa
    La ve el agente Solo si la escribes Sí, pero la toma como verdad aunque esté mal Siempre, marcada como fuente que prevalece
    La ven los tests No Solo si el test repite el número Sí, si el test lee el mismo catálogo
    Riesgo principal Olvido. Cada prompt es un punto de fallo Divergencia silenciosa con negocio Catálogo desactualizado tratado como verdad

    La tercera columna no es perfecta. Pero es la única en la que el error tiene un solo sitio donde corregirse.

    El servicio de ejemplo del tutorial de harness engineering

    Estructura mínima:

    catalog/shipping-service.yaml
    src/shipping.ts
    src/shipping.test.ts
    harness/context.ts
    harness/run.ts
    

    El catálogo. En empresas grandes esto no es un YAML suelto: vive en un developer portal. Backstage lo modela con un catalog-info.yaml por servicio y Port lo expone como entidades con API. Si no tienes nada de eso, un fichero versionado en el repo sirve igual para empezar:

    # catalog/shipping-service.yaml
    name: shipping-service
    owner: team-checkout
    rules:
      freeShippingThreshold:
        value: 39
        unit: EUR
        source: "Decisión de negocio Q3-2026 (OPS-412)"
      standardShippingCost:
        value: 4.95
        unit: EUR
    agent:
      editableFiles:
        - src/shipping.ts
      testCommand: "npx vitest run src/shipping.test.ts"
      maxAttempts: 3
    

    Fíjate en el bloque agent. Qué ficheros puede tocar el agente lo decide el owner del servicio, no el agente ni quien lanza la tarea.

    El código con el bug:

    // src/shipping.ts
    const FREE_SHIPPING_THRESHOLD = 50;
    const STANDARD_SHIPPING = 4.95;
    
    export function shippingCost(subtotal: number): number {
      if (subtotal < 0) throw new RangeError('subtotal negativo');
      return subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING;
    }
    

    Paso 1: cargar el contexto de servicio para el agente

    El primer trabajo del harness es construir el contexto de servicio para el agente: leer el catálogo, validarlo y fallar si está incompleto.

    // harness/context.ts
    import { readFileSync } from 'node:fs';
    import { parse } from 'yaml';
    
    export interface Rule {
      value: number | string;
      unit?: string;
      source?: string;
    }
    
    export interface ServiceContext {
      name: string;
      owner: string;
      rules: Record<string, Rule>;
      agent: { editableFiles: string[]; testCommand: string; maxAttempts: number };
    }
    
    export function loadServiceContext(path: string): ServiceContext {
      const raw = parse(readFileSync(path, 'utf8'));
      if (
        !raw?.name ||
        !raw?.rules ||
        !Array.isArray(raw?.agent?.editableFiles) ||
        typeof raw?.agent?.testCommand !== 'string' ||
        !Number.isInteger(raw?.agent?.maxAttempts)
      ) {
        throw new Error(`Catálogo inválido: ${path}`);
      }
      return raw as ServiceContext;
    }
    

    Si el catálogo está roto, el harness para. No arranca con contexto a medias. En producción yo validaría esto con un schema de Zod en vez de con cinco condiciones a mano, pero la idea es la misma: el contexto entra validado o no entra.

    Los tests leen el umbral del mismo catálogo que el harness, así que nunca se quedan desfasados respecto a la regla de negocio:

    // src/shipping.test.ts
    import { describe, it, expect } from 'vitest';
    import { loadServiceContext } from '../harness/context';
    import { shippingCost } from './shipping';
    
    const { rules } = loadServiceContext('catalog/shipping-service.yaml');
    const threshold = Number(rules.freeShippingThreshold.value);
    const standard = Number(rules.standardShippingCost.value);
    
    describe('shippingCost', () => {
      it('es gratis a partir del umbral del catálogo', () => {
        expect(shippingCost(threshold)).toBe(0);
      });
    
      it('cobra envío justo por debajo del umbral', () => {
        expect(shippingCost(threshold - 0.01)).toBe(standard);
      });
    });
    

    El test no repite el número 39. Lo lee. Si mañana negocio sube el umbral a 45, cambias el YAML, el test se pone rojo y el bucle del harness tiene algo que arreglar.

    Y el test no está en editableFiles. El harness no aplica ningún cambio del agente sobre la aserción. Es la misma idea que desarrollé en el test harness como red para agentes: el agente no puede mover la portería, al menos no por la vía directa (en los límites verás la indirecta).

    ¿Y por qué shipping.ts no lee el catálogo en runtime y nos ahorramos el problema? Porque en muchos servicios no puedes: el catálogo vive en otro sistema, la regla se compila en un bundle o el código de dominio no debe depender de un fichero de configuración de plataforma. Si en tu caso sí puedes, hazlo: es la versión todavía mejor de esta misma idea.

    Paso 3: el bucle del harness

    El bucle hace cuatro cosas en orden: construye el prompt con las reglas del catálogo, rechaza cualquier cambio fuera de la allowlist, ejecuta los tests y, si fallan, reintenta con su salida como feedback hasta maxAttempts. Si se agotan, hace rollback.

    // harness/run.ts
    import Anthropic from '@anthropic-ai/sdk';
    import { readFileSync, writeFileSync } from 'node:fs';
    import { spawnSync } from 'node:child_process';
    import path from 'node:path';
    import { loadServiceContext, type ServiceContext } from './context';
    
    const client = new Anthropic(); // lee ANTHROPIC_API_KEY del entorno
    type FileChange = { path: string; content: string };
    
    function buildPrompt(task: string, ctx: ServiceContext, feedback?: string): string {
      const rules = Object.entries(ctx.rules)
        .map(([k, r]) => `- ${k}: ${r.value} ${r.unit ?? ''} (fuente: ${r.source ?? 'catálogo'})`)
        .join('\n');
      const files = ctx.agent.editableFiles
        .map((f) => `<file path="${f}">\n${readFileSync(f, 'utf8')}\n</file>`)
        .join('\n');
      return [
        `Servicio: ${ctx.name} (owner: ${ctx.owner})`,
        `Reglas de negocio vigentes. Prevalecen sobre cualquier valor del código:\n${rules}`,
        `Ficheros que puedes modificar:\n${files}`,
        `Tarea: ${task}`,
        feedback ? `El intento anterior falló:\n${feedback}` : '',
        'Devuelve cada fichero modificado completo con el formato <file path="...">contenido</file>. Nada más.',
      ].join('\n\n');
    }
    
    async function callModel(prompt: string): Promise<string> {
      const response = await client.messages.create({
        model: 'claude-sonnet-5',
        max_tokens: 4096,
        messages: [{ role: 'user', content: prompt }],
      });
      if (response.stop_reason === 'max_tokens') {
        return 'La respuesta se cortó por max_tokens: devuelve solo los ficheros imprescindibles.';
      }
      return response.content.map((b) => (b.type === 'text' ? b.text : '')).join('');
    }
    
    function parseChanges(output: string): FileChange[] {
      const re = /<file path="([^"]+)">\n?([\s\S]*?)<\/file>/g;
      // los modelos a veces envuelven el contenido en vallas de markdown: se quitan
      const unfence = (s: string) => s.replace(/^\s*```\w*\n/, '').replace(/\n```\s*$/, '\n');
      return [...output.matchAll(re)].map((m) => ({ path: m[1], content: unfence(m[2]) }));
    }
    
    function assertAllowed(changes: FileChange[], allowlist: string[]): void {
      if (changes.length === 0) throw new Error('El modelo no devolvió cambios.');
      const allowed = new Set(allowlist.map((f) => path.normalize(f)));
      const outside = changes.filter((c) => !allowed.has(path.normalize(c.path)));
      if (outside.length > 0) {
        throw new Error(`Rechazado. Fuera de la allowlist: ${outside.map((c) => c.path).join(', ')}`);
      }
    }
    
    function runTests(command: string): { ok: boolean; output: string } {
      // El proceso hijo no hereda nada que parezca un secreto
      const env = Object.fromEntries(
        Object.entries(process.env).filter(([k]) => !/KEY|TOKEN|SECRET|PASSWORD/i.test(k)),
      );
      // timeout: un test colgado cuenta como intento fallido (status null), no bloquea el harness
      const r = spawnSync(command, { shell: true, encoding: 'utf8', env, timeout: 120_000 });
      return { ok: r.status === 0, output: `${r.stdout}\n${r.stderr}`.slice(-4000) };
    }
    
    export async function runHarness(task: string, catalogPath: string) {
      const ctx = loadServiceContext(catalogPath);
      const originals = new Map(ctx.agent.editableFiles.map((f) => [f, readFileSync(f, 'utf8')]));
      let feedback: string | undefined;
      let succeeded = false;
    
      try {
        for (let attempt = 1; attempt <= ctx.agent.maxAttempts; attempt++) {
          const changes = parseChanges(await callModel(buildPrompt(task, ctx, feedback)));
          try {
            assertAllowed(changes, ctx.agent.editableFiles);
          } catch (err) {
            feedback = (err as Error).message;
            continue;
          }
          for (const c of changes) writeFileSync(path.normalize(c.path), c.content);
          const tests = runTests(ctx.agent.testCommand);
          if (tests.ok) {
            succeeded = true;
            return { status: 'ready-for-review' as const, attempt };
          }
          feedback = tests.output;
        }
        return { status: 'failed' as const, lastFeedback: feedback };
      } finally {
        // rollback también si el modelo o el disco lanzan a mitad
        if (!succeeded) for (const [f, content] of originals) writeFileSync(f, content);
      }
    }
    

    Y la llamada, en un harness/main.ts que ejecutas con npx tsx harness/main.ts (tsx resuelve los imports sin extensión y el top-level await):

    // harness/main.ts
    import { runHarness } from './run';
    
    const result = await runHarness(
      'Un pedido de 45 € pagó envío y debería haber sido gratis. Corrige shippingCost.',
      'catalog/shipping-service.yaml',
    );
    console.log(result);
    

    Fíjate en lo que no dice la tarea: no dice "el umbral es 39". Quien abre el ticket no tiene por qué saberlo. El harness lo sabe porque lo lee del catálogo.

    Qué controla el harness y qué no controla el modelo

    Repasa el bucle con los ojos de quien lo audita.

    El contexto. El modelo recibe la regla con su fuente y la instrucción de que prevalece sobre el código. Ya no tiene que elegir entre un 50 que ve y un 39 que nadie le ha dicho.

    El radio de acción. Si el modelo devuelve src/shipping.test.ts o ../catalog/shipping-service.yaml, no están en la allowlist y el cambio entero se rechaza (path.normalize solo evita que ./src/shipping.ts se rechace por la forma de escribir la ruta). Se rechaza completo, no se aplica a medias. El motivo del rechazo vuelve como feedback en el siguiente intento.

    La verificación. El agente no decide cuándo ha terminado. Termina cuando vitest sale con código 0. Si falla, la salida de los tests (los últimos 4.000 caracteres, para no inflar el contexto) vuelve al prompt.

    El final. Tres intentos y rollback, también si la API falla a mitad de bucle: el finally restaura los ficheros pase lo que pase. El mejor resultado posible es ready-for-review: el harness no hace git push, no abre PR contra main y no tiene credenciales de despliegue. Filtrar variables de entorno es una red de seguridad, no la garantía. La garantía real es que el proceso del harness nunca tenga esas credenciales cargadas.

    Esta forma de pensar el trabajo con agentes, con contexto explícito, límites y verificación antes de que un humano mire, es la que seguimos en Construye con IA para pasar de idea a producto sin que el agente decida cosas que no le tocan.

    Lo que dice la gente que ya lo hace

    OpenAI popularizó el término con Harness engineering: Leveraging Codex in an agent-first world. El hilo de Hacker News sobre ese post tiene más de 200 comentarios, y los que aportan algo coinciden en lo mismo. Un usuario resume su receta y el primer punto es literalmente: "Give Claude/Codex a way to verify its own work (browser, smoke tests, e2e tests, high-fidelity local environment)".

    Otro avisa de lo que pasa sin ese control. Sin supervisión, "it'll start creating slop or hardcoding solutions". Aquí el número sigue en el código: el agente cambiará 50 por 39, y eso también es hardcodear. La diferencia es que ahora el hardcodeo tiene un vigilante. Si el número del código se separa del catálogo, el test que lee el catálogo se pone rojo. Si quieres eliminar la copia, el siguiente paso es que shipping.ts lea el umbral del catálogo en runtime.

    Límites de este enfoque de harness engineering

    El catálogo puede mentir y el agente se lo cree. Le has dicho al modelo que el catálogo prevalece sobre el código. Si alguien deja el YAML desactualizado, el harness propaga el error con toda la confianza del mundo, y el test también, porque lee el mismo fichero. En el mismo hilo de HN alguien lo dice de la documentación en general: "Become outdated fast". El catálogo necesita un owner con nombre y apellidos, y los cambios de regla tienen que pasar por revisión como cualquier otro código.

    La allowlist por fichero es gruesa. Permitir src/shipping.ts permite todo lo que hay en src/shipping.ts. El agente puede cambiar el umbral y, de paso, reescribir el manejo de errores. La allowlist limita dónde toca el agente, no qué hace. Para eso sigue haciendo falta revisar el diff.

    Los tests ejecutan código del agente. La allowlist controla lo que escribe el harness, no lo que hace shipping.ts cuando vitest lo importa. Ese código puede escribir en el test o leer un .env del disco. Dos defensas baratas: después de los tests, comprueba con git status --porcelain que solo cambiaron ficheros de la allowlist, y ejecuta los tests en un contenedor sin credenciales ni acceso de escritura fuera de src/.

    Los tests solo verifican lo que cubren. Dos tests sobre el umbral no dicen nada de redondeos, divisas o pedidos con descuento. Un cambio que pasa en verde no está bien: simplemente no rompe lo que mides.

    Los reintentos cuestan. Cada intento reenvía los ficheros permitidos completos, las reglas y la salida de los tests. Con un fichero pequeño da igual. Con cinco ficheros de 800 líneas y maxAttempts: 5, el coste se multiplica y el modelo empieza a arrastrar contexto de intentos fallidos. Si en tres intentos no pasa, el problema suele estar en la tarea o en los tests, no en la falta de insistencia.

    Devolver ficheros completos no escala. Para ficheros grandes vas a querer diffs o herramientas de edición en lugar de ficheros enteros, y entonces la validación de la allowlist se hace sobre las rutas del diff. La idea no cambia; cambia el parser.

    El feedback es de un solo intento. Si un intento se rechaza por la allowlist, ese mensaje sustituye a la salida de los tests del intento anterior, y el siguiente prompt enseña el fichero ya modificado, no el original. Para tareas acotadas basta; para tareas largas conviene acumular el historial de feedback.

    Qué hacer hoy

    Elige una regla de negocio que hoy vive como constante en tu código y que alguien de fuera de ingeniería puede cambiar: un umbral, un plazo, un límite de reintentos. Muévela a un fichero de catálogo versionado, haz que su test la lea de ahí y quita el número del test.

    Solo con eso, sin agente, ya tienes una regla con un único sitio de verdad. Luego conectar el harness es un centenar largo de líneas.

    Si quieres los fundamentos de cómo funcionan los agentes por dentro (bucles, herramientas, memoria, seguridad), tienes gratis el ebook El Developer Agéntico. Y si quieres llevar esta disciplina más atrás, a la especificación antes de que exista el código, el libro de Spec-Driven Development es el siguiente paso.

    Preguntas frecuentes

    ¿Por qué no basta con poner la regla de negocio en el prompt?

    Porque depende de que quien escribe el prompt la conozca y se acuerde. Cada tarea nueva es una oportunidad de olvidarla. Si el harness la lee de una fuente de verdad, la regla llega siempre, aunque el ticket lo haya escrito alguien que no sabe que existe.

    ¿Necesito Backstage o Port para aplicar esto?

    No. Un fichero YAML o JSON versionado en el repo del servicio es suficiente para empezar. Backstage o Port tienen sentido cuando hay decenas de servicios y varios equipos y necesitas un catálogo centralizado con owners, API y búsqueda. El harness solo necesita una función que devuelva el contexto validado, venga de donde venga.

    ¿Qué pasa si el agente intenta modificar los tests para que pasen?

    El harness rechaza el cambio completo porque el fichero de test no está en la allowlist, y el motivo vuelve como feedback en el siguiente intento. Por eso los tests nunca deben estar en la lista de ficheros editables cuando el objetivo es corregir código contra ellos.

    ¿Por qué el código no lee directamente el catálogo en runtime?

    Si puedes, hazlo: es la versión más sólida de la idea, porque elimina la copia del valor. En muchos servicios no es viable (el catálogo vive en otro sistema, la regla se compila en un bundle o el dominio no debe depender de la configuración de plataforma). En esos casos, el test que lee el catálogo es lo que evita que el código y la regla se separen.

    ¿Cuántos reintentos debería permitir el harness?

    Entre dos y tres para tareas acotadas como esta. Más intentos rara vez arreglan lo que los primeros no arreglaron, y el coste en tokens crece con cada uno. Si falla de forma sistemática, revisa la tarea, el contexto o los tests antes de subir el límite.

    ¿Por qué el harness no despliega si los tests pasan?

    Porque unos tests verdes solo demuestran que no se ha roto lo que está cubierto. El despliegue necesita una revisión humana del diff y el pipeline de CI habitual. El harness entrega un cambio listo para revisar; quien tiene permisos de despliegue es otra persona, u otro sistema con sus propios controles.


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

  • Validación en NestJS: DTOs, class-validator y sus trampas

    Validación en NestJS: DTOs, class-validator y sus trampas

    El bug tardó once días en aparecer y cuatro minutos en explicarse.

    Una API NestJS, endpoint de registro. El controlador recibía el body, lo pasaba al servicio, el servicio lo pasaba al ORM. Limpio, corto, elegante. Alguien mandó un role: "admin" de más en el JSON y se creó una cuenta con permisos de administrador.

    Lo que más duele: el proyecto tenía validación en NestJS. Tenía DTOs con decoradores. Tenía el ValidationPipe registrado globalmente. Y aun así el campo pasó.

    Porque el pipe estaba puesto sin opciones. new ValidationPipe(), tal cual. Eso valida que los campos declarados cumplan sus reglas, pero no elimina los que no declaraste. Y en una API, lo que no declaras es exactamente lo que te van a mandar.

    La validación en NestJS bien montada no es una capa de seguridad más. Es el contrato entre el mundo exterior y tu aplicación. Cuando lo defines bien, un montón de código defensivo que hoy vive en tus servicios simplemente desaparece.


    El DTO en NestJS es un contrato, no un tipo

    Un DTO (Data Transfer Object) en NestJS es una clase que define la forma exacta de los datos que un endpoint acepta. No la forma que esperas: la que aceptas.

    // src/users/dto/create-user.dto.ts
    import {
      IsEmail,
      IsInt,
      IsOptional,
      IsString,
      MaxLength,
      Min,
      MinLength,
    } from 'class-validator';
    
    export class CreateUserDto {
      @IsEmail({}, { message: 'El email no tiene un formato válido' })
      email: string;
    
      @IsString()
      @MinLength(12, { message: 'La contraseña necesita al menos 12 caracteres' })
      password: string;
    
      @IsString()
      @MinLength(2)
      @MaxLength(60)
      fullName: string;
    
      @IsOptional()
      @IsInt()
      @Min(18)
      age?: number;
    }
    

    En el controlador no haces nada especial:

    @Post()
    create(@Body() dto: CreateUserDto) {
      return this.usersService.create(dto);
    }
    

    Y aquí viene la primera regla que no se negocia: CreateUserDto tiene que ser una class, nunca una interface.

    Las interfaces de TypeScript desaparecen al compilar. Nest lee el tipo del parámetro en runtime con reflect-metadata; si es una interface, el metatype que recibe es Object, no hay decoradores que leer, y el pipe deja pasar el payload entero sin decir nada. Es un fallo silencioso, que son los peores — y es el mismo problema de fondo que trato en cómo tipar correctamente una API REST en TypeScript: el tipo estático no te protege de lo que llega por el cable.


    ValidationPipe en NestJS: las opciones que sí importan

    El ValidationPipe de NestJS es el pipe integrado que intercepta el payload de una request, lo compara contra los decoradores del DTO usando class-validator y lanza un 400 Bad Request con la lista de errores si algo no cumple. Viene en @nestjs/common y no valida nada por sí solo: todo depende de las opciones con las que lo construyas.

    Registra el pipe globalmente y configúralo. Este es el bloque que uso en producción:

    // src/main.ts
    import { ValidationPipe } from '@nestjs/common';
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      app.useGlobalPipes(
        new ValidationPipe({
          whitelist: true,
          forbidNonWhitelisted: true,
          forbidUnknownValues: true,
          transform: true,
          transformOptions: { enableImplicitConversion: false },
          stopAtFirstError: false,
        }),
      );
    
      await app.listen(process.env.PORT ?? 3000);
    }
    bootstrap();
    

    Qué hace cada una, sin adornos.

    Opción Por defecto en Nest Recomendado Qué te juegas
    whitelist false true Sin ella, las propiedades que no declaras en el DTO llegan intactas al servicio
    forbidNonWhitelisted false true Sin ella los campos de más se borran en silencio en vez de devolver un 400
    forbidUnknownValues false (Nest lo fuerza) true Con false, un objeto sin metadatos de class-validator pasa la validación entera
    transform false true Sin ella recibes un objeto literal, no una instancia del DTO: sin métodos ni getters
    transformOptions.enableImplicitConversion false false Si lo pones a true, ?onlyActive=false activa el filtro, porque Boolean('false') es true
    stopAtFirstError false false Con true devuelves un error por request en vez de todos los del formulario de una vez

    whitelist: true elimina del objeto toda propiedad que no tenga al menos un decorador de validación. Este flag por sí solo habría evitado el bug de la historia. role no estaba en el DTO, así que se habría borrado antes de llegar al servicio.

    forbidNonWhitelisted: true sube la apuesta: en lugar de borrar en silencio, devuelve un 400 diciendo qué propiedad sobra. Lo prefiero, porque el silencio de whitelist a secas te oculta que el frontend lleva tres sprints mandando un campo muerto.

    transform: true convierte el JSON plano en una instancia real de la clase. Sin esto recibes un objeto literal con la forma correcta, no un CreateUserDto: si tu DTO tiene métodos o getters, no existen.

    stopAtFirstError viene en false por defecto en class-validator, y así lo dejo: quiero devolver todos los errores del formulario de una vez, no obligar al cliente a hacer seis viajes.

    Y luego está forbidUnknownValues, que merece su propia sección porque es la trampa gorda.


    La trampa: forbidUnknownValues no vale lo que crees

    La documentación de class-validator es tajante: forbidUnknownValues vale true por defecto y recomienda no tocarlo, porque desactivarlo hace que objetos desconocidos pasen la validación.

    Ahora mira el constructor del ValidationPipe de Nest:

    // packages/common/pipes/validation.pipe.ts
    this.validatorOptions = { forbidUnknownValues: false, ...validatorOptions };
    

    Nest lo pone a false salvo que tú lo pidas explícitamente. Es una decisión deliberada de compatibilidad hacia atrás (viene del issue 10683), no un despiste. Pero el efecto práctico es que la opción que class-validator considera crítica está desactivada por defecto en tu API de Nest.

    Muerde cuando el pipe valida un objeto del que class-validator no tiene metadatos: un DTO sin decoradores, uno que olvidaste importar bien, una clase generada dinámicamente. Con false eso pasa limpiamente. Con true falla y te enteras.

    Ponlo a true explícitamente y pasa tu suite después. Si algo se rompe, es que algo no se estaba validando.


    Objetos anidados: @ValidateNested sin @Type no valida nada

    @ValidateNested() solo valida un objeto anidado si va acompañado de @Type(() => Clase). Sin @Type, class-validator no sabe en qué clase instanciar el valor, no encuentra metadatos y deja pasar el objeto entero. Es la segunda trampa, y la he visto en más proyectos que la anterior.

    // src/orders/dto/create-order.dto.ts
    import { Type } from 'class-transformer';
    import {
      ArrayMinSize,
      IsArray,
      IsInt,
      IsNotEmpty,
      IsString,
      Matches,
      Min,
      ValidateNested,
    } from 'class-validator';
    
    export class AddressDto {
      @IsString()
      @IsNotEmpty()
      street: string;
    
      @IsString()
      @IsNotEmpty()
      city: string;
    
      @Matches(/^\d{5}$/, { message: 'El código postal debe tener 5 dígitos' })
      zipCode: string;
    }
    
    export class OrderItemDto {
      @IsString()
      @IsNotEmpty()
      sku: string;
    
      @IsInt()
      @Min(1)
      quantity: number;
    }
    
    export class CreateOrderDto {
      @IsString()
      @IsNotEmpty()
      customerId: string;
    
      @ValidateNested()
      @Type(() => AddressDto)
      shippingAddress: AddressDto;
    
      @IsArray()
      @ArrayMinSize(1)
      @ValidateNested({ each: true })
      @Type(() => OrderItemDto)
      items: OrderItemDto[];
    }
    

    @Type() viene de class-transformer, no de class-validator, y es la que le dice en qué clase instanciar el objeto anidado.

    Si la quitas, el anidado se queda como objeto plano, @ValidateNested() no encuentra metadatos asociados a ese valor y no comprueba nada. La request pasa y shippingAddress llega a tu servicio con lo que sea que mandaran.

    @ValidateNested sin @Type es decoración. Van siempre en pareja. Y en arrays, { each: true } es obligatorio o solo validas el array como un todo.


    Update sin duplicar el DTO

    No copies y pegues el DTO de create para poner todo opcional.

    // src/users/dto/update-user.dto.ts
    import { OmitType, PartialType } from '@nestjs/mapped-types';
    import { CreateUserDto } from './create-user.dto';
    
    export class UpdateUserDto extends PartialType(
      OmitType(CreateUserDto, ['email'] as const),
    ) {}
    

    PartialType hace todas las propiedades opcionales manteniendo sus reglas. OmitType quita las que no deben poder cambiarse. Tienes también PickType e IntersectionType.

    Aviso de la documentación oficial que cuesta caro ignorar: si usas @nestjs/swagger o @nestjs/graphql, importa los mapped types desde esos paquetes, no desde @nestjs/mapped-types. La doc oficial lo deja en "efectos secundarios varios y no documentados", sin concretar. En mi experiencia se manifiesta casi siempre como esquemas OpenAPI vacíos que nadie sabe explicar.


    Query params y la conversión implícita

    Los query params llegan siempre como string. Aquí es donde mucha gente activa enableImplicitConversion: true y se olvida del tema. Mala idea.

    // src/users/dto/find-users-query.dto.ts
    import { Transform, Type } from 'class-transformer';
    import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
    
    export class FindUsersQueryDto {
      @IsOptional()
      @Type(() => Number)
      @IsInt()
      @Min(1)
      page: number = 1;
    
      @IsOptional()
      @Type(() => Number)
      @IsInt()
      @Min(1)
      @Max(100)
      limit: number = 20;
    
      @IsOptional()
      @Transform(({ value }) => value === 'true' || value === true)
      @IsBoolean()
      onlyActive: boolean = false;
    
      @IsOptional()
      @IsString()
      search?: string;
    }
    

    onlyActive lo transformo a mano por una razón muy concreta.

    Cuando activas enableImplicitConversion, class-transformer usa el tipo reflejado por TypeScript y aplica el constructor correspondiente. Para booleanos, el código es literalmente return Boolean(value).

    Y Boolean('false') en JavaScript es true. Cualquier string no vacío lo es.

    Es decir: ?onlyActive=false te activa el filtro. Tu API hace lo contrario de lo que pide el cliente, devuelve un 200 y no aparece un solo error en los logs. Lo he depurado dos veces y las dos me llevó más de una hora.

    Deja enableImplicitConversion en false y sé explícito propiedad a propiedad con @Type() y @Transform(). Más verboso, y correcto.


    Validadores custom: cuando el decorador no existe

    Los decoradores integrados cubren tipo y formato. Las reglas de negocio no. Para eso escribes una clase que implementa ValidatorConstraintInterface.

    // src/users/validators/is-email-available.validator.ts
    import { Injectable } from '@nestjs/common';
    import {
      ValidationArguments,
      ValidatorConstraint,
      ValidatorConstraintInterface,
    } from 'class-validator';
    import { UsersRepository } from '../users.repository';
    
    @ValidatorConstraint({ name: 'isEmailAvailable', async: true })
    @Injectable()
    export class IsEmailAvailableConstraint implements ValidatorConstraintInterface {
      constructor(private readonly users: UsersRepository) {}
    
      async validate(email: unknown): Promise<boolean> {
        if (typeof email !== 'string') return false;
        const existing = await this.users.findByEmail(email.toLowerCase());
        return existing === null;
      }
    
      defaultMessage(args: ValidationArguments): string {
        return `El email ${args.value} ya está registrado`;
      }
    }
    

    Lo enganchas al DTO con @Validate:

    import { IsEmail, Validate } from 'class-validator';
    import { IsEmailAvailableConstraint } from '../validators/is-email-available.validator';
    
    export class CreateUserDto {
      @IsEmail()
      @Validate(IsEmailAvailableConstraint)
      email: string;
    
      // ...resto de propiedades
    }
    

    Para que la inyección de dependencias funcione necesitas dos cosas. Primero, declarar el constraint como provider en su módulo. Segundo, decirle a class-validator que use el contenedor de Nest:

    // src/main.ts
    import { useContainer } from 'class-validator';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      useContainer(app.select(AppModule), { fallbackOnErrors: true });
      // ...el resto del bootstrap: useGlobalPipes, listen
    }
    

    fallbackOnErrors: true no es opcional: sin él, Nest lanza una excepción en cuanto class-validator le pide al contenedor una clase que no está registrada como provider.

    Una advertencia: consultar la base de datos durante la validación es best effort, no una garantía. Entre que el validador pregunta y el servicio inserta hay una ventana de carrera. El índice único de la tabla sigue siendo la fuente de verdad; el validador solo sirve para devolver un 400 legible en vez de un 500 con un error del driver.

    A favor juega el orden del ciclo de vida de Nest: los pipes se ejecutan después de los guards. Cuando ese validador toca la base de datos, la request ya está autenticada.


    Testea el validador como lógica pura

    Un validador custom es una clase con una dependencia. No necesitas levantar un TestingModule.

    // src/users/validators/is-email-available.validator.spec.ts
    import { IsEmailAvailableConstraint } from './is-email-available.validator';
    
    describe('IsEmailAvailableConstraint', () => {
      const usersRepository = { findByEmail: jest.fn() };
      const constraint = new IsEmailAvailableConstraint(usersRepository as never);
    
      beforeEach(() => jest.resetAllMocks());
    
      it('acepta un email que no existe', async () => {
        usersRepository.findByEmail.mockResolvedValue(null);
        await expect(constraint.validate('nuevo@dominicode.com')).resolves.toBe(true);
      });
    
      it('rechaza un email ya registrado', async () => {
        usersRepository.findByEmail.mockResolvedValue({ id: '1' });
        await expect(constraint.validate('bezael@dominicode.com')).resolves.toBe(false);
      });
    
      it('normaliza a minúsculas antes de consultar', async () => {
        usersRepository.findByEmail.mockResolvedValue(null);
        await constraint.validate('Bezael@Dominicode.com');
        expect(usersRepository.findByEmail).toHaveBeenCalledWith('bezael@dominicode.com');
      });
    });
    

    Tres tests, cero infraestructura. Si tu validador necesita un módulo entero para poder testearse, tiene demasiada responsabilidad.


    Cuándo NO usar class-validator

    class-validator es la librería de decoradores (@IsEmail, @MinLength, @ValidateNested) sobre la que NestJS construye toda su validación de entrada. No es un paquete de Nest: es un proyecto independiente, y ahí está la parte incómoda.

    class-validator va por la 0.15.1, publicada el 26 de febrero de 2026. El parón fuerte fue entre la 0.14.1 (enero de 2024) y la 0.14.2 (mayo de 2025): dieciséis meses sin release. Desde entonces ha recuperado ritmo — 0.14.3 en noviembre de 2025, 0.14.4 y 0.15.1 en febrero de 2026. Está vivo.

    class-transformer es otra historia. Su última versión publicada es la 0.5.1, de noviembre de 2021. Casi cinco años sin release, y es la pieza de la que dependen @Type, @Transform, transform: true y toda la conversión implícita que acabamos de ver. No está roto, pero tampoco se está arreglando.

    En paralelo, NestJS 12 salió el 27 de agosto de 2026 con soporte nativo de Standard Schema. Los decoradores de parámetro aceptan una opción schema, y hay un pipe nuevo para validarla:

    // src/users/schemas/create-user.schema.ts
    import { z } from 'zod';
    
    export const createUserSchema = z.strictObject({
      email: z.email(),
      password: z.string().min(12),
      fullName: z.string().min(2).max(60),
      age: z.number().int().min(18).optional(),
    });
    
    export type CreateUserDto = z.infer<typeof createUserSchema>;
    
    // src/main.ts
    import { StandardSchemaValidationPipe } from '@nestjs/common';
    
    app.useGlobalPipes(new StandardSchemaValidationPipe());
    
    // src/users/users.controller.ts
    @Post()
    create(@Body({ schema: createUserSchema }) body: CreateUserDto) {
      return this.usersService.create(body);
    }
    

    Aquí CreateUserDto es un tipo inferido, no una clase. El esquema y el tipo son la misma cosa, así que no puedes desincronizarlos. Con decoradores son dos verdades separadas que mantienes a mano, y ahí es donde entran los bugs.

    La comparación honesta:

    class-validator Zod / Standard Schema
    Fuente de verdad Tipo + decoradores (dos) Esquema (una)
    DI en validadores Sí, vía useContainer No de serie
    Mapped types (PartialType) Sí .partial(), .omit(), .pick()
    OpenAPI @nestjs/swagger maduro Nativo en v12, o nestjs-zod
    Mantenimiento Lento (class-transformer congelado) Activo
    Reutilizar en el frontend No Sí, mismo esquema

    Mi criterio, sin vender humo: si tu proyecto ya es class-based de arriba abajo (entidades TypeORM, Swagger, validadores con DI), class-validator sigue siendo el camino de menor fricción y no hay que migrarlo por moda.

    Si empiezas hoy en Nest 12, si compartes contratos con un frontend TypeScript, o si validas salidas de un LLM —donde necesitas parsear, transformar y reintentar en el mismo sitio—, Zod gana con claridad. Si sigues en v11 y quieres esa ruta, nestjs-zod (5.5.0, julio de 2026) te da createZodDto, el pipe y la serialización de respuestas. Ojo: sus peer dependencies todavía declaran @nestjs/common ^10 || ^11, así que para v12 aún no es opción.

    Sobre este tema escribí a fondo en validación en runtime con Zod y TypeScript, y si quieres dominar la librería entera —transformaciones, refinamientos, esquemas compuestos— la trabajo paso a paso en el curso de Zod para TypeScript.

    Y si aún estás eligiendo framework, esta capa de validación es uno de los argumentos de más peso a favor de Nest frente a opciones más ligeras, como analicé en Hono vs NestJS vs Express y al mirar la alternativa más directa a Nest, ExpressoTS 4.0.


    Checklist: revisa hoy tu validación en NestJS

    Abre tu main.ts. Si ves new ValidationPipe() sin opciones, ya tienes trabajo para los próximos veinte minutos:

    1. Añade whitelist: true y forbidNonWhitelisted: true.
    2. Pon forbidUnknownValues: true explícitamente y ejecuta tu suite de tests.
    3. Busca en el proyecto @ValidateNested y comprueba que cada uno tiene su @Type() al lado.
    4. Si tienes enableImplicitConversion: true, quítalo y haz explícitas las conversiones.

    Después vete a tus servicios y borra los if (!dto.email) throw .... Esos guardias existen porque en algún momento nadie confió en la entrada. Cuando el contrato vive en el DTO, sobran: es el principio que desarrollo en programación defensiva en TypeScript, donde la mejor defensa es la que se aplica una vez, en el borde, y no en cada función.

    Esa es la fortaleza silenciosa de NestJS. No es que valide. Es que, bien montado, te deja escribir servicios que asumen datos correctos porque lo son.

    Si quieres verlo aplicado sobre un proyecto real, con la capa de validación, los tests y las decisiones de arquitectura completas, lo trabajamos en Dominicode Labs. Y si lo que te interesa es NestJS llevado al terreno de la IA, monté el streaming de respuestas en tiempo real con el Vercel AI SDK sobre esta misma base. En vídeo, subo NestJS y arquitectura backend cada semana en el canal de YouTube.


    Preguntas frecuentes

    ¿Qué es un DTO en NestJS?

    Un DTO (Data Transfer Object) en NestJS es una clase que describe la forma exacta del payload que un endpoint acepta: qué propiedades existen, de qué tipo son y qué reglas cumplen. Se declara con decoradores de class-validator y se usa como tipo del parámetro @Body(), @Query() o @Param() en el controlador.

    Tiene que ser una class y no una interface: las interfaces desaparecen al compilar y en runtime no queda nada a lo que asociar los decoradores. Y no es solo documentación — con el ValidationPipe configurado, el DTO es lo que decide qué request entra y cuál se rechaza con un 400.

    ¿Puedo usar una interface en lugar de una clase para el DTO?

    No, si quieres que se valide. Las interfaces desaparecen en la transpilación, así que en runtime no hay nada a lo que asociar los decoradores: Nest recibe Object como metatype y el ValidationPipe deja pasar el payload entero.

    Si te molesta escribir clases, la alternativa real es la ruta de esquemas: con Zod y el StandardSchemaValidationPipe de Nest 12 el DTO sí puede ser un tipo inferido, porque la validación no depende de metadatos de runtime sino del esquema que pasas al decorador.

    Tengo el ValidationPipe puesto y la validación en NestJS no salta. ¿Qué reviso?

    Por orden. Que el DTO sea una clase y que el tipo del parámetro en el controlador sea exactamente esa clase, no any ni un union. Que emitDecoratorMetadata y experimentalDecorators estén a true en tu tsconfig.json, porque sin ellos no hay metadatos de tipo que leer.

    Después, que el pipe esté registrado donde crees: si lo pusiste con APP_PIPE en un módulo de feature en lugar del root, solo aplica a ese ámbito. Y si lo que pasa es que un objeto entero se cuela sin validarse, mira forbidUnknownValues, que Nest fuerza a false cuando no lo declaras tú.

    ¿class-validator sigue mantenido en 2026?

    Sí, aunque a ritmo irregular. La versión actual es la 0.15.1, del 26 de febrero de 2026, publicada justo un día después de la 0.14.4. El bache serio fueron los dieciséis meses entre la 0.14.1 (enero de 2024) y la 0.14.2 (mayo de 2025); desde ahí ha vuelto a publicar con regularidad. Sigue en 0.x diez años después de su primera versión, lo cual dice bastante sobre su compromiso de estabilidad de API.

    El problema mayor es class-transformer, su dependencia inseparable: última versión 0.5.1, noviembre de 2021. Toda la lógica de @Type, @Transform y transform: true corre sobre un paquete que lleva casi cinco años sin release. No es motivo para migrar mañana, sí para tenerlo en cuenta al empezar un proyecto nuevo.

    ¿Dónde valido las reglas de negocio: en el DTO o en el servicio?

    En el DTO va todo lo que es forma: tipos, formatos, longitudes, rangos, campos requeridos, estructura de los objetos anidados. Son reglas que se responden mirando solo el payload.

    En el servicio va todo lo que necesita contexto: si este usuario puede hacer esta operación, si el stock alcanza, si el pedido está en un estado que admite ese cambio. La prueba rápida es preguntarte si la regla depende de quién hace la petición o del estado actual del sistema. Si depende, no es validación de entrada, es lógica de dominio, y meterla en un decorador te va a complicar los tests.

    ¿Merece la pena migrar un proyecto grande de class-validator a Zod?

    Rara vez de golpe, y casi nunca por el argumento de que "está más moderno". Rehacer DTOs, mapped types, validadores con inyección de dependencias y la integración con Swagger son semanas de trabajo sin una sola feature nueva para el usuario.

    Lo que sí funciona es la convivencia. Nest 12 mantiene el flujo de class-validator plenamente soportado junto al de Standard Schema, así que escribes con Zod lo nuevo y dejas lo existente como está. Se migra por presión real —un bug de sincronía entre tipo y validación, un esquema que necesitas compartir con el frontend— y no por calendario.


    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.

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

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

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

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

    Una palabra.

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

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

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

    Resumen rápido:

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

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

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

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

    El bucle tiene cinco pasos:

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


    Implementación del bucle en TypeScript

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

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

    Tres decisiones que no son cosméticas.

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

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

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

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


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

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

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

    2. Techo estricto, y cuenta intentos, no esperanzas

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

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

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

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

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


    Un oráculo por cada tipo de error

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

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

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

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

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

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


    Qué errores son curables y cuáles no

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

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

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

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


    Cuando el segundo intento también falla

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

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


    Por dónde empezar mañana

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

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

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

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

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


    Preguntas frecuentes

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

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

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

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

    ¿No reintenta ya generateObject por su cuenta con maxRetries?

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

    ¿Cuántos intentos debería permitir?

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

    Y esa es la parte que casi nadie aprovecha.


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

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

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

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

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

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

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

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


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

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

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

    La diferencia en el JSON Schema generado es esta:

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

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

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

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


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

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

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

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

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

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

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

    Dos avisos prácticos:

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

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


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

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

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

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

    produce esto:

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

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

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

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

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

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

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


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

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

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

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

    Con z.discriminatedUnion() cambia la estructura:

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

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

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

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

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


    El orden de los campos no es cosmética

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

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

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

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

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

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


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

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

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

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


    Resumen: qué lee el modelo en cada caso

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

    Qué puedes cambiar hoy

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

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

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

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

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


    Preguntas frecuentes

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

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

    ¿Usar .optional() está mal siempre?

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

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

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

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

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

    ¿Esto aplica igual con el Vercel AI SDK?

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


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

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

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

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

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

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

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

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


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

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

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

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

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


    Los dos callejones sin salida antes de llegar aquí

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

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

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

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

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

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

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

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

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

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

    Un agente que revisa solicitudes de reembolso:

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

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

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

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


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

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

    1. Enums cerrados, nunca strings libres

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

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

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

    2. Códigos de motivo, no explicaciones

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

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

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

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

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

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

    Dos reglas:

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

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

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

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

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

    5. Los campos opcionales fabrican tests frágiles

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

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

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

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


    El test que resulta

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

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

    Dos detalles que importan.

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

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

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


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

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

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

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

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

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

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

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

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


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

    Cuando la calidad de la salida es irreductiblemente textual.

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

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


    Por dónde empezar mañana

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

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

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

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

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


    Preguntas frecuentes

    ¿Qué es exactamente un eval determinista?

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

    ¿Con temperature 0 ya tengo determinismo garantizado?

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

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

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

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

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

    ¿Estos tests corren en cada push?

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


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

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

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

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

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

    Y el agente era inútil.

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

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

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

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


    API REST describe recursos, servidor MCP describe capacidades

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

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

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

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

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

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


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

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

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

    Con eso, esto queda fuera:

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

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


    La tool de intención: consolida, no traduzcas

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

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

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

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

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


    Las cuatro piezas que no se traducen solas

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

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

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

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

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


    Servidor MCP en TypeScript con el SDK oficial

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

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

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

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

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

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

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


    Checklist de migración de API REST a servidor MCP

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

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

    Lo que haría hoy

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

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

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

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


    Preguntas frecuentes

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

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

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

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

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

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

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

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

    ¿La tool debe devolver JSON o texto?

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


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

  • Bun 1.4.1: tu bundle de zod pesa un 79% menos sin tocar código

    Bun 1.4.1: tu bundle de zod pesa un 79% menos sin tocar código

    Hace unas semanas abrí el análisis de bundle de un backend que despliego como ejecutable compilado desde hace dos años. Hono, Postgres, validación con zod. Nada exótico.

    Lo que me llamó la atención no fue el tamaño total, fue el reparto. Zod se llevaba una porción absurda para los cuatro schemas que usaba de verdad.

    La explicación estaba en un await import() dinámico enterrado en un módulo de rutas. Ese import funcionaba como un muro: el bundler no podía mirar al otro lado, así que metía el módulo entero por si acaso.

    Bun 1.4.1 tira ese muro. El número que publica el equipo de Bun es exactamente el que me interesaba: zod pasa de 375,3 KB a 77,3 KB. Un 79% menos sin tocar una línea de código de aplicación.

    El titular fácil de esta release es "202 issues cerrados". Ese no es el titular. El titular es que el bundler de Bun ha dejado de ser su eslabón flojo.

    Bun 1.4.1 se publicó el 4 de septiembre de 2026 y su cambio principal está en el bundler: bun build ya hace tree-shaking a través de import() dinámico y de export * as. Medido por el equipo de Bun sobre librerías reales, zod 4.5 baja de 375,3 KB a 77,3 KB (79% menos), effect 3.22 de 369,1 KB a 163,6 KB (56% menos) y fp-ts 2.16 de 21,8 KB a 3,2 KB (85% menos). En el runtime llegan HTTP/2 y HTTP/1.1 en el mismo puerto de Bun.serve(), Bun.write() con streaming a disco y crypto.argon2(). Se actualiza con bun upgrade, y las notas no anuncian ningún breaking change: son 202 issues cerrados sobre la 1.4.0.

    Todas las cifras de este post salen de las notas oficiales de la release de Bun v1.4.1.


    Tree-shaking a través de import() dinámico: el gran cambio de Bun 1.4.1

    Hasta ahora un import dinámico era una frontera para el bundler: sabía que el módulo existía, pero no qué se usaba de él, así que la única opción segura era incluirlo entero.

    Mira este caso, sacado de las notas de la release:

    // is.ts exports isNumber, isOdd, and isEven
    const { isOdd } = await import("./is");
    console.log(isOdd(3));
    

    Antes, ese import() arrastraba isNumber e isEven al bundle aunque nadie las llamara nunca. Ahora Bun analiza el destructuring y solo entra isOdd.

    Parece un detalle de juguete. Multiplícalo por una librería con cientos de exports y entiendes el 79% menos de zod.

    Y ahí está lo interesante: los patrones de carga perezosa que usábamos para "no cargar la validación hasta que haga falta" llevaban años penalizándonos. Cargábamos tarde, sí, pero cargábamos todo.

    En el curso de Zod para TypeScript trabajo justo esa parte: componer schemas por módulo en lugar de un barrel gigante que el bundler tiene que adivinar.

    export * as: el otro sitio donde se escondía el peso

    El segundo sitio donde Bun 1.4.1 recorta peso es más silencioso: los re-exports.

    export * as algo from "./modulo" es el re-export de namespace: agrupas un módulo entero bajo un nombre y lo sacas por tu index.ts. Cómodo para importar, veneno para el tamaño final.

    Bun 1.4.1 optimiza ese re-export. El resultado con fp-ts 2.16: de 21,8 KB a 3,2 KB. Un 85% menos.

    Si tus barrels reexportan con export * as —y en arquitectura por features es habitual agrupar así—, este cambio te afecta aunque no lo hayas pedido. Si solo usas export * plano, mide antes de celebrar.

    Code splitting y ejecutables que arrancan antes

    El code splitting también mejora. El dashboard de Medusa pasa de 349 a 245 archivos JS generados: menos peticiones, menos overhead y menos coste de arranque en el navegador.

    Y en builds de navegador con --splitting, Bun ahora emite module preloading, así que los chunks se piden en paralelo en lugar de en cascada.

    La parte que más me sorprendió está en los ejecutables compilados: arrancan alrededor de un 20% más rápido, con el bytecode más compacto según el equipo de Bun. El caso que usan de ejemplo es Claude Code: de 397 ms a 318 ms de arranque, y la instalación baja de 376 MB a 207 MB.

    Ochenta milisegundos escasos suenan a nada hasta que recuerdas cuántas veces al día lanzas tu CLI favorita. Y fíjate en el detalle: Claude Code, el ejemplo que usa Bun para medirse, está compilado con Bun. Si vives en la terminal con herramientas así —el flujo que enseño en Construye con IA—, ese 20% lo notas en la fricción, no en el benchmark.

    Bun.serve() con HTTP/2 y HTTP/1.1 en el mismo puerto

    En el runtime, Bun 1.4.1 trae dos cambios que afectan a código real: HTTP/2 en Bun.serve() y Bun.write() con streaming a disco.

    El primero es un flag:

    Bun.serve({
      tls: { key, cert },
      http2: true,
      fetch(req) {
        return new Response("hi");
      },
    });
    

    Sin proxy delante, sin segundo servidor, sin decidir de antemano qué protocolo habla tu cliente.

    Si todavía estás en la pregunta anterior a esta —por qué Bun y no Node—, la respuesta larga la escribí en Bun reemplazando a Node.js en backend TypeScript. Aquí doy por hecho que ya la tienes resuelta.

    El segundo cambio: Bun.write() hace streaming a disco en vez de bufferizar en memoria.

    await Bun.write("./big.tar.gz", await fetch(url)); // => bytes escritos
    await Bun.write("./out.txt", readableStream); // antes escribía "[object ReadableStream]"
    

    En una descarga de 128 MiB, el pico de RSS baja de 161 MB a 13 MB. Ese primer ejemplo es el patrón que todos escribimos alguna vez para guardar un archivo remoto, y hasta hoy cargaba el archivo entero en RAM antes de tocar el disco.

    La segunda línea es directamente un bug arreglado: pasar un ReadableStream escribía el texto "[object ReadableStream]" en tu fichero. Si tienes archivos corruptos en producción con ese contenido exacto, ya sabes de dónde venían.

    También hay pause(), resume() y la propiedad isPaused en el WebSocket cliente, que es la pieza que faltaba para hacer backpressure decente:

    import { createWriteStream } from "node:fs";
    
    const file = createWriteStream("./feed.ndjson");
    const socket = new WebSocket("wss://example.com/feed");
    
    socket.addEventListener("message", (event) => {
      if (!file.write(event.data)) {
        socket.pause();
        file.once("drain", () => socket.resume());
      }
    });
    

    Sin eso, un feed rápido y un disco lento acababan siempre en el mismo sitio: memoria creciendo hasta que algo revienta.

    Memoria y detalles que se notan a las tres de la mañana

    La memoria en reposo baja. Un SSR de Next.js pasa de 222 MB a 142 MB una vez terminada la carga. En un contenedor con límite de 256 MB, eso es la diferencia entre dormir tranquilo y recibir alertas de OOM.

    Ojo con la lectura fácil de este tipo de cifras: lo mismo pasó con el 90% menos de memoria de Next.js 16.3, donde el número era real pero no era el de todo el mundo.

    En Buffer hay dos mejoras concretas, y quiero ser preciso porque esto se lee por ahí como "Buffer 9x más rápido": son 9,2x en writeFloatLE() y 7,2x en writeUInt8(). Métodos específicos, no la clase entera.

    AsyncLocalStorage va unas 2x más rápido y ya no asigna memoria en cada await. Si tienes tracing o contexto de request atravesando toda la aplicación, esto lo estabas pagando en cada salto asíncrono.

    Y llegan crypto.argon2() y crypto.argon2Sync(). Bun ya hasheaba con argon2id desde Bun.password; lo nuevo es tenerlo en la API de crypto, que es lo que te ahorra reescribir el módulo de auth cuando portas código de Node.

    Todos los números de Bun 1.4.1, en una tabla

    Caso medido Antes Bun 1.4.1 Mejora
    Bundle de zod 4.5 375,3 KB 77,3 KB 79% menos
    Bundle de effect 3.22 369,1 KB 163,6 KB 56% menos
    export * as con fp-ts 2.16 21,8 KB 3,2 KB 85% menos
    Archivos JS del dashboard de Medusa 349 245 30% menos
    Arranque de Claude Code 397 ms 318 ms 20% menos
    Instalación de Claude Code 376 MB 207 MB 45% menos
    Pico de RSS al descargar 128 MiB 161 MB 13 MB 92% menos
    Memoria en reposo de un SSR de Next.js 222 MB 142 MB 36% menos
    Buffer.writeFloatLE() 2,85 ns 0,31 ns 9,2x
    Buffer.writeUInt8() 2,24 ns 0,31 ns 7,2x

    Fuente: notas de la release de Bun v1.4.1.

    bun install --offline: es para tu CI, no para ti

    Dos flags nuevos en bun install.

    --offline falla si algo no está en caché. Cero red, sin excusas. --prefer-offline es la versión suave: usa la caché y se salta el refetch de metadatos, pero baja lo que falte.

    Los mismos valores viven en bunfig.toml:

    # bunfig.toml
    [install]
    offline = true       # equivale a --offline
    # prefer = "offline" # equivale a --prefer-offline
    

    --offline en local te va a molestar. En CI, con la caché restaurada antes del install, convierte un fallo de red del registry en un error reproducible en lugar de un build rojo aleatorio a las once de la noche.

    Y para monorepos, los workspaces admiten paquetes con node_modules autocontenidos:

    {
      "workspaces": {
        "packages": ["apps/*"],
        "selfContained": ["apps/desktop"]
      }
    }
    

    Útil cuando una app del monorepo se distribuye sola —un binario de escritorio, un contenedor— y necesita sus dependencias dentro, no hoisted arriba del todo.

    Si vienes de otro gestor, el movimiento de fondo es el mismo en todo el ecosistema: velocidad y builds reproducibles. Lo analicé cuando salió pnpm 12 reescrito en Rust.

    ¿Merece la pena actualizar a Bun 1.4.1? Qué migrar hoy y qué no

    Bun 1.4.1 no es una revolución. Es una release de consolidación, y el bloque del bundler es lo único que justifica que la instales esta semana.

    Tu situación Qué hacer con Bun 1.4.1 Por qué
    Compilas frontend o librerías con bun build Actualiza hoy La ganancia de tamaño es gratis y se mide en diez minutos
    Distribuyes ejecutables con bun build --compile Actualiza hoy 20% menos de arranque y 45% menos de instalación sin tocar código
    Bun solo ejecuta tu servidor Actualiza sin prisa HTTP/2 y menos RSS no arreglan nada que hoy funcione
    Estás en Node y te funciona No migres Una release no justifica cambiar de runtime en producción

    La primera fila es la que tiene premio inmediato: actualizas, relanzas el build y comparas el tamaño. Diez minutos. La tercera puede esperar al próximo sprint, porque HTTP/2 en el mismo puerto y menos RSS están muy bien, pero no arreglan nada que hoy funcione.

    Y no migres de Node a Bun solo por esta release. Si Node te sirve, esto no cambia la ecuación. Lo que cambia es que el argumento "el bundler de Bun todavía no está maduro" ya no se sostiene.

    Mi orden de trabajo esta semana es este: actualizar, lanzar el build, medir el bundle antes y después, y revisar dónde teníamos import() dinámicos puestos como optimización que en realidad no optimizaban nada.

    Ese ejercicio de medir antes y después es de lo que más discutimos en Dominicode Labs, porque casi siempre aparece algo que llevaba años ahí sin que nadie lo mirara.


    Preguntas frecuentes

    ¿Tengo que cambiar código para ganar el 79% menos de zod?

    No. La optimización ocurre al empaquetar, no al escribir: actualizas Bun, vuelves a lanzar bun build y el bundle sale más pequeño. Lo único que conviene revisar es si tus import() dinámicos destructuran lo que usan de verdad, porque cuanto más explícito seas al importar, más trabajo puede hacer el bundler.

    ¿El tree-shaking a través de import() dinámico funciona fuera de Bun?

    No. Es una optimización del bundler de Bun, no del lenguaje ni de TypeScript, así que el beneficio solo llega cuando el build lo hace bun build. Ejecutar tu aplicación con Bun pero empaquetar con otro bundler no te da estos números.

    ¿HTTP/2 en Bun.serve() necesita TLS?

    Sí en la práctica: el ejemplo oficial de la release configura tls junto a http2: true, que es el escenario normal para HTTP/2 en internet. Lo nuevo no es soportar el protocolo, es que HTTP/2 y HTTP/1.1 conviven en el mismo puerto, sin levantar dos servidores ni decidir por adelantado qué habla el cliente.

    ¿Cómo actualizo a Bun 1.4.1 y hay breaking changes?

    Con bun upgrade, y las notas de la release no anuncian ningún breaking change: es una versión de consolidación que cierra 202 issues sobre la 1.4.0. Si compilas con bun build, el orden sensato es actualizar, relanzar el build y comparar el tamaño antes y después.

    ¿bun install –offline sirve para CI?

    Sí, es su mejor uso: restauras la caché de Bun al principio del job y lanzas bun install --offline, de modo que si falta algo el build falla ahí y no a mitad del pipeline. Si prefieres algo menos estricto, --prefer-offline se salta el refetch de metadatos pero descarga lo que no esté en caché.

    ¿Merece la pena migrar de Node a Bun solo por esta release?

    No. Una release no justifica una migración de runtime en un proyecto que ya está en producción y funciona. Lo que sí merece la pena es probar bun build como bundler aunque ejecutes con Node: esa prueba cuesta una tarde y te da datos de tu proyecto en lugar de benchmarks ajenos.


    Si quieres ver este tipo de análisis en vídeo, con el proyecto delante y midiendo en directo, 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.

  • Desplegar agentes LangChain en producción sin perder el estado

    Desplegar agentes LangChain en producción sin perder el estado

    En local funcionaba perfecto.

    El agente respondía, llamaba a sus herramientas, escribía token a token en la terminal. Lo metí en un contenedor y lo subí. A los pocos días empecé a ver el mismo patrón en los logs: conversaciones cortadas a mitad, y usuarios que volvían y encontraban un agente sin memoria de nada.

    No había ningún error en el código del agente. El agente estaba bien. Lo que estaba mal era todo lo que hay entre el agente y el usuario.

    Y es que desplegar agentes LangChain en producción no se parece a desplegar una API REST. Una API REST responde en 200 milisegundos y no recuerda nada. Un agente tarda treinta segundos, mantiene la conexión abierta todo ese rato, guarda estado entre turnos y llama a servicios externos que fallan. Cuatro propiedades que rompen, una por una, las suposiciones sobre las que está construida tu infraestructura.

    Si todavía estás decidiendo la forma del agente —grafo de estados o bucle— eso lo desarrollé en LangGraph TypeScript: cuándo un grafo gana al while loop. Este post empieza donde acaba aquel: ya tienes el grafo, ahora hay que sacarlo del portátil.

    Todo el código está escrito contra langchain 1.5 y @langchain/langgraph 1.4, con @langchain/langgraph-checkpoint-postgres 1.0. Es importante que mires las versiones: la API de creación de agentes y la de streaming cambiaron en LangChain 1, y casi todos los tutoriales que vas a encontrar están escritos contra la anterior.

    Última revisión: 31 de agosto de 2026. Si LangGraph publica una 2.x, el PostgresSaver es lo primero que hay que volver a comprobar.


    Los 3 fallos al desplegar agentes LangChain en producción

    Los tres fallos que rompen un agente en producción son la conexión que corta el proxy, el estado que vive en RAM y la herramienta sin timeout. No son los que parecen, y son los que me han costado tiempo de verdad:

    # Fallo Por qué pasa
    1 La conexión se corta a mitad de respuesta El proxy cierra la conexión por inactividad: mientras el modelo "piensa" no viajan bytes
    2 El agente pierde la memoria El historial vivía en RAM y el contenedor se reinició o escaló a otra instancia
    3 Una herramienta se cuelga y arrastra al proceso Sin timeout ni cancelación, la petición queda colgada y la conexión SSE ocupando memoria

    Conviene desmontar un mito antes de seguir, porque lo he leído muchas veces: el bucle del agente no bloquea el event loop. El trabajo de un agente es esperar respuestas HTTP del modelo y de sus herramientas, así que es I/O, y Node o Bun siguen atendiendo peticiones mientras tanto. Lo que sí se te agota es otra cosa. La memoria que ocupa cada conexión abierta, el límite de concurrencia de tu plataforma, y los sockets que nadie cerró porque el cliente se fue sin avisar.


    El estado: sácalo de la RAM el primer día

    El estado de un agente LangGraph no puede vivir en una variable del proceso: en cuanto el contenedor se reinicia o escala, la conversación desaparece. Este es el arreglo con más retorno y el más barato de aplicar.

    Mientras el estado vive en memoria, tu agente recuerda hasta el próximo despliegue. Y como los reinicios no los decides tú —los decide el autoescalado, un health check o un deploy—, no es un riesgo teórico: pasa.

    La solución en LangGraph es un checkpointer, que guarda el estado del grafo después de cada paso en una base de datos externa:

    import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
    
    const checkpointer = PostgresSaver.fromConnString(process.env.DATABASE_URL!);
    
    // Solo la primera vez: crea las tablas que necesita el checkpointer.
    await checkpointer.setup();
    

    Ese setup() va en el paso de migraciones de tu despliegue, no en el arranque de cada instancia. Si lo dejas en el boot y levantas diez réplicas, tienes diez procesos creando las mismas tablas a la vez.

    A partir de ahí, cada conversación se identifica con un thread_id. El agente no "recuerda" nada en memoria: al recibir un turno nuevo, lee el estado de ese hilo desde Postgres, avanza y vuelve a escribirlo.

    Eso cambia una propiedad importante de tu servicio: pasa a ser reemplazable. Puedes matar el contenedor, desplegar una versión nueva o levantar diez réplicas detrás de un balanceador, y cualquiera de ellas puede continuar cualquier conversación, porque el estado no está en ninguna de ellas.


    El servidor: streaming que sobrevive al proxy

    El segundo problema es la conexión. Un agente tarda decenas de segundos en completar una respuesta, y durante buena parte de ese tiempo no manda ni un byte, porque está esperando al modelo o ejecutando una herramienta.

    Para un proxy —Nginx, Cloudflare, el balanceador de tu PaaS— una conexión abierta que no transmite nada es una conexión muerta, y la cierra.

    Así que hay tres cosas que hacer, y las tres se olvidan:

    • Enviar las cabeceras SSE inmediatamente, para que el proxy sepa que esto es un stream y no espere a tener el cuerpo entero.
    • Mandar un latido cada pocos segundos aunque no haya nada que decir, para que la conexión nunca esté inactiva.
    • Abortar el trabajo si el cliente se va, o seguirás pagando tokens de una respuesta que ya no lee nadie.
    import express from "express";
    import { createAgent } from "langchain";
    
    // Necesita @langchain/anthropic instalado y ANTHROPIC_API_KEY en el entorno.
    const agent = createAgent({
      model: "anthropic:claude-sonnet-5",
      tools: [buscarPedido], // la definimos más abajo
      checkpointer,          // el PostgresSaver de arriba
    });
    
    const app = express();
    app.use(express.json());
    
    app.post("/api/agent/chat", async (req, res) => {
      // El thread_id se valida contra el usuario autenticado: si no,
      // cualquiera puede leer la conversación de cualquier otro.
      const { threadId, message } = req.body;
    
      res.setHeader("Content-Type", "text/event-stream");
      res.setHeader("Cache-Control", "no-cache, no-transform");
      res.setHeader("Connection", "keep-alive");
      res.setHeader("X-Accel-Buffering", "no"); // que Nginx no acumule el stream
      res.flushHeaders();                       // sin esto, el proxy espera
    
      // Latido: mantiene viva la conexión frente al idle timeout del proxy.
      const heartbeat = setInterval(() => {
        if (res.writableEnded || res.destroyed) return;
        res.write(": ping\n\n");
      }, 15_000);
    
      // Si el cliente cierra la pestaña, se cancela el trabajo del agente.
      const controller = new AbortController();
      res.on("close", () => {
        clearInterval(heartbeat);
        controller.abort();
      });
    
      try {
        const stream = await agent.streamEvents(
          { messages: [{ role: "user", content: message }] },
          {
            version: "v3",
            configurable: { thread_id: threadId },
            signal: controller.signal,
          },
        );
    
        await Promise.all([
          (async () => {
            for await (const m of stream.messages) {
              for await (const token of m.text) {
                res.write(`data: ${JSON.stringify({ type: "token", text: token })}\n\n`);
              }
            }
          })(),
          (async () => {
            for await (const call of stream.toolCalls) {
              res.write(`data: ${JSON.stringify({ type: "tool", name: call.name })}\n\n`);
            }
          })(),
        ]);
    
        res.write("data: [DONE]\n\n");
      } catch (err) {
        if (!controller.signal.aborted) {
          res.write(`data: ${JSON.stringify({ type: "error" })}\n\n`);
        }
      } finally {
        clearInterval(heartbeat);
        res.end();
      }
    });
    
    // Cloud Run y casi cualquier PaaS inyectan PORT: no lo fijes a mano.
    app.listen(process.env.PORT ?? 3000);
    

    Dos detalles que merecen su párrafo.

    El version: "v3". Es la API de streaming con proyecciones tipadas, y aparece en langchain a partir de la 1.4.0. En vez de recibir un chorro plano de eventos y filtrar por nombre, iteras stream.messages para los tokens y stream.toolCalls para las herramientas, cada uno por su lado. Si copias un tutorial que usa version: "v2" y compara event.event === "on_chat_model_stream", estás escribiendo contra la API anterior.

    Un aviso que no vas a encontrar en esos tutoriales: LangChain la marca como experimental en su propia definición de tipos —"This v3 stream is experimental and its API may change in future releases"—. La uso igualmente porque la alternativa envejece peor, pero fija la versión en tu package.json y no la des por estable.

    El signal. RunnableConfig acepta un AbortSignal, y es lo que convierte el res.on("close") en una cancelación real en lugar de un simple return. Sin él, el cliente se va pero tu servidor sigue generando tokens contra la API del modelo hasta el final.

    Si vienes del stack de Vercel, el mismo problema con otras piezas lo resolví en streaming de respuestas de IA con NestJS y el Vercel AI SDK.


    Las herramientas: donde se cuelga todo

    El fallo que más veces he tenido que diagnosticar en producción no está en el modelo ni en el grafo. Está en una herramienta que llama a una API de terceros que ese día tarda cuarenta segundos en responder.

    Sin timeout propio, esa herramienta se lleva por delante la petición entera. El usuario ve un cursor parpadeando, la conexión sigue abierta consumiendo memoria, y tú no sabes en qué paso se quedó.

    La regla es simple: toda herramienta que salga a la red lleva su propio timeout, más corto que el de la petición completa, y devuelve un texto en lugar de reventar. Ésta es la buscarPedido que usa el agente de arriba:

    import { tool } from "langchain";
    import * as z from "zod";
    
    const buscarPedido = tool(
      async ({ id }) => {
        try {
          const res = await fetch(`${API}/pedidos/${id}`, {
            signal: AbortSignal.timeout(8_000), // esta tool falla en 8s o no falla
          });
          return JSON.stringify(await res.json());
        } catch {
          // El agente lee esto y decide: reintentar o admitir que no puede.
          return "El servicio de pedidos no respondió en 8 segundos.";
        }
      },
      {
        name: "buscar_pedido",
        description: "Busca un pedido por su identificador",
        schema: z.object({ id: z.string() }),
      },
    );
    

    Y que falle está bien. Un error controlado vuelve al agente como resultado de la herramienta, el modelo lo lee y puede reintentar o decir que no ha podido. Una herramienta colgada, en cambio, no le da ninguna información con la que trabajar: el agente se queda esperando y el usuario también.

    Si además quieres que la herramienta muera cuando el cliente cierra la pestaña, combina su propio timeout con el signal que le llega en el config: el AbortSignal.timeout por sí solo no escucha esa cancelación.

    Ese diseño de herramientas —contrato claro, fallo rápido y un error que el modelo pueda leer— es el que trabajo paso a paso en el curso Construye con IA con Claude Code.

    Cómo evitar que ese reintento se convierta en un bucle sin fin lo desarrollé en Agentic Loop en TypeScript. Y cómo probar todo esto en CI antes de que llegue a producción, en test harness para agentes de IA.


    Qué pasa de verdad cuando el contenedor se reinicia

    Aquí es donde casi todas las guías te dicen una verdad a medias. "Con un checkpointer no pierdes el estado" es cierto, pero conviene saber exactamente qué se salva y qué no.

    Si el contenedor muere mientras un agente está a mitad de una tarea:

    • Se conserva todo lo que ya estaba confirmado en el último checkpoint: los turnos anteriores, los resultados de las herramientas que ya terminaron y el estado del grafo hasta ese punto.
    • Se pierde el paso en vuelo. Los tokens que se estaban generando en ese momento no están en ninguna parte, y la conexión SSE del cliente se cae con el proceso.
    • No se reanuda solo. No hay nadie que retome la tarea al arrancar el contenedor nuevo. Y ojo con lo que significa "volver a llamar". El checkpoint se escribe por paso del grafo. Si el proceso murió justo después de que el modelo pidiera una herramienta, el estado guardado termina en un mensaje del asistente con tool_calls y ninguna respuesta. Mandar ahí un mensaje nuevo del usuario produce un 400 del proveedor, porque todo tool_use exige su tool_result. Antes de aceptar el turno siguiente hay que cerrar el paso pendiente de ese hilo.

    Esto tiene una consecuencia de diseño que hay que asumir pronto: el thread_id tiene que sobrevivir al navegador y estar atado al usuario. Que lo genere el cliente está bien; que el servidor se lo crea sin comprobar contra quién ha iniciado sesión, no. Y si el identificador solo vive en la memoria del navegador, un refresco lo pierde y la conversación se queda huérfana en la base de datos: existe, pero nadie sabe pedirla.

    Y si la tarea es larga de verdad —un informe que tarda diez minutos, un procesamiento por lotes—, el patrón correcto no es este. Es aceptar la petición, devolver un identificador y ejecutar el trabajo en una cola aparte, con el cliente consultando el progreso. Un agente detrás de una petición HTTP tiene sentido para conversación, no para trabajo de fondo.


    Empaquetar y desplegar agentes LangChain en producción

    Empaquetar un agente es un Dockerfile normal con un detalle que rompe builds: desde Bun 1.2 el lockfile por defecto es bun.lock, no bun.lockb.

    FROM oven/bun:1-alpine
    WORKDIR /app
    
    # Desde Bun 1.2 el lockfile por defecto es bun.lock (texto), no bun.lockb.
    COPY package.json bun.lock ./
    RUN bun install --frozen-lockfile --production
    
    COPY . .
    
    ENV NODE_ENV=production
    USER bun
    CMD ["bun", "run", "src/server.ts"]
    

    Si copias un Dockerfile de hace un par de años vas a ver COPY package.json bun.lockb ./, y con un proyecto actual esa línea falla porque ese archivo ya no existe.

    Y un .dockerignore al lado, que es el otro detalle que rompe builds:

    node_modules
    .git
    .env*
    

    Sin él, el COPY . . te mete el node_modules de tu portátil encima del que acabas de instalar dentro del contenedor, con binarios compilados para otra plataforma.

    Sobre dónde desplegarlo, lo único que importa de verdad es cuánto tiempo te dejan tener una conexión abierta:

    Plataforma Timeout por defecto Máximo Qué tienes que tocar
    Cloud Run 300 s (5 min) 3.600 s (60 min) Subir el timeout y fijar una instancia mínima para no pagar arranque en frío por conversación
    Render · Railway · Fly Idle timeout propio, más corto No es ilimitado El latido SSE: sin él la conexión cuenta como inactiva y la cortan

    Los números de Cloud Run salen de su documentación de timeouts. Para un agente conversacional con streaming, el valor de fábrica se queda corto en cuanto una herramienta se ralentiza.

    Y aquí hay una distinción que cuesta un incidente aprender: el latido no te salva del timeout de Cloud Run. El latido derrota los timeouts de inactividad, que es lo que aplican los PaaS. El de Cloud Run es duración máxima de la petición, y corta igual aunque estés emitiendo tokens sin parar. En todos los que he probado, además, ninguno mantiene una conexión abierta indefinidamente.

    Un agente en producción además habla con servicios externos, y ahí el problema deja de ser el deploy y pasa a ser el transporte y la autenticación. Eso lo cubrí en MCP en producción: lo que se rompe cuando tu server sale del portátil.


    No despliegues a ciegas

    En un backend clásico te basta con los errores HTTP. En un agente necesitas ver el árbol de decisiones: qué prompt se envió, qué herramienta se ejecutó, cuánto tardó y qué costó. Sin eso, "va lento" y "responde mal" son incidencias que no puedes investigar.

    No lo desarrollo aquí porque ya tiene su sitio. El planteamiento está en cómo monitorear agentes de IA en producción, la implementación en Langfuse paso a paso, y la parte que te va a llegar en la factura, en medir el consumo de tokens.


    Checklist antes de pulsar deploy

    1. El estado, fuera del proceso. Checkpointer con setup() ejecutado y thread_id generado y persistido por el cliente.
    2. El stream, blindado. flushHeaders(), latido cada 15 segundos y AbortSignal conectado al cierre de la conexión.
    3. Las herramientas, con timeout propio. Más corto que el de la petición, y que fallen con un error que el agente pueda leer.
    4. El timeout de la plataforma, subido. El de fábrica está pensado para APIs que responden rápido, no para agentes.
    5. Trazas desde el primer despliegue. No desde el primer incidente.

    Las arquitecturas de agentes que tengo funcionando, con sus fallos y lo que costó arreglarlos, las comparto cada semana en Dominicode Labs.

    Que un agente funcione en tu portátil es un experimento. Que sobreviva a un reinicio es ingeniería.


    Preguntas frecuentes

    ¿Cómo se despliega un agente LangChain en producción?

    Desplegar agentes LangChain en producción son cuatro decisiones, no una. Primera: sacar el estado del proceso con un checkpointer persistente —PostgresSaver sobre Postgres— para que cualquier réplica pueda continuar cualquier conversación. Segunda: servir la respuesta por SSE con flushHeaders(), un latido cada 15 segundos y un AbortSignal atado al cierre del cliente, para que ningún proxy corte el stream. Tercera: poner timeout propio a cada herramienta que salga a la red, más corto que el de la petición. Y cuarta: subir el timeout de la plataforma, que de fábrica está pensado para APIs que responden en milisegundos. El contenedor en sí es lo de menos.

    ¿Postgres o Redis para el checkpointer?

    Postgres por defecto. El estado de una conversación es un dato que quieres conservar, consultar y auditar más tarde, y Postgres te lo da sin trabajo extra. Redis tiene sentido cuando la latencia de lectura del estado empieza a notarse de verdad o cuando el historial es efímero y no te importa perderlo. Empezar por Redis "porque es más rápido" suele salir caro el día que necesitas saber qué le contestó el agente a un cliente hace tres semanas.

    Si el contenedor se reinicia a mitad de una tarea, ¿se reanuda sola?

    No. Se conserva el estado hasta el último checkpoint confirmado, pero el paso que estaba en vuelo se pierde y nadie retoma la tarea por su cuenta. La reanudación la dispara el cliente cuando vuelve a llamar con el mismo thread_id, siempre que el paso pendiente se cierre antes de mandar un mensaje nuevo. Si el hilo se quedó con una petición de herramienta sin responder, el proveedor devuelve un 400. Y si necesitas que el trabajo termine sí o sí aunque nadie esté mirando, eso no va en una petición HTTP: va en una cola.

    ¿SSE o WebSocket para un agente?

    SSE en la mayoría de casos. La comunicación de un agente conversacional es casi toda en un sentido —el servidor manda tokens— y SSE va sobre HTTP normal, así que atraviesa proxies y balanceadores sin configuración especial. La reconexión automática te la da EventSource, pero solo habla GET: con el endpoint POST de arriba consumes el stream con fetch y ReadableStream, y la reconexión la escribes tú. WebSocket compensa cuando de verdad necesitas un canal bidireccional con mucho tráfico del cliente hacia el servidor, y a cambio te complica el despliegue.

    ¿Cuánto timeout pongo en Cloud Run?

    El valor de fábrica son 5 minutos y el máximo son 60. Para un agente conversacional, subirlo a 10-15 minutos suele ser suficiente: cubre las respuestas largas y las herramientas lentas sin dejar conexiones zombis eternas. Ponerlo al máximo no es gratis, porque una conexión colgada ocupa una instancia durante todo ese tiempo.

    ¿Esto vale con otro modelo que no sea Claude?

    Sí. La arquitectura —checkpointer externo, streaming con latido, cancelación y timeouts por herramienta— es independiente del proveedor. Lo único que cambia es el identificador del modelo que le pasas a createAgent y el paquete de integración correspondiente.


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