Tag: TypeScript

  • TypeScript 7.0: el compilador en Go que cambia tu día a día

    TypeScript 7.0: el compilador en Go que cambia tu día a día

    El año pasado revisé un monorepo Nx de un cliente con más de 600 archivos TypeScript compartiendo tipos entre ocho aplicaciones Angular. Cada vez que alguien tocaba una interfaz común, tsc --noEmit tardaba entre 50 y 90 segundos en confirmar si habíamos roto algo.

    Multiplica eso por cada dev, cada commit, cada CI run del día, y tienes horas enteras de tu equipo mirando una terminal en vez de escribir código.

    Ayer, 8 de julio de 2026, Microsoft anunció que TypeScript 7 llegó a disponibilidad general. Y no es una release más con un par de utility types nuevos.

    Es la primera vez en la historia del lenguaje que el compilador deja de estar escrito en TypeScript y pasa a ser un binario nativo en Go. El proyecto se llamó tsgo durante la beta; en la versión final, tsc ya es ese compilador nativo — no hay que instalar nada aparte.

    Llevamos años escuchando la misma queja en cualquier proyecto TypeScript grande: "esto sería instantáneo si estuviera en Rust o en Go, como esbuild o swc". Microsoft por fin le hizo caso a su propia comunidad, y el resultado es el cambio de infraestructura más importante que ha tenido TypeScript desde que existe.


    Qué cambió realmente en TypeScript 7 (no es un upgrade cosmético)

    Hasta la versión 6.0, tsc era un compilador bootstrapped: TypeScript compilando TypeScript, que a su vez corría sobre el motor de JavaScript de Node. Funcional, pero con un techo de rendimiento que ni V8 ni ningún truco de caché podían romper del todo.

    TypeScript 7 tira ese techo abajo. Microsoft reescribió el type-checker, el parser y el emitter en Go, un lenguaje compilado con gestión de memoria y concurrencia nativa.

    La lógica de chequeo de tipos se mantiene estructuralmente idéntica a la de 6.0 — Microsoft no aprovechó la reescritura para "arreglar" reglas de inferencia. Si tu código compilaba limpio en 6.0 con stableTypeOrdering activado y sin flags deprecados, debería compilar igual en 7.0.

    TypeScript 6.0 TypeScript 7.0
    Compilador Bootstrapped (TS sobre JS/Node) Nativo en Go (tsgotsc)
    Velocidad de type-checking Base ~10x más rápido (16.7x con --checkers 8)
    strict Opcional Obligatorio
    target: es5 Soportado Eliminado
    Módulos amd / umd / systemjs / none Soportados Eliminados (CommonJS sigue vivo)
    baseUrl Soportado Eliminado
    API programática estable Disponible Llega en TypeScript 7.1

    TypeScript 7: los números que sí importan

    Microsoft reporta que TypeScript 7.0 es, en promedio, unas 10 veces más rápido que TypeScript 6.0.

    Pero el dato que de verdad vale la pena mirar es el de VS Code con el flag --checkers 8 (paralelización del type-checker en varios hilos): pasó de 125.7 segundos a 7.51 segundos. Un speedup de 16.7x en el chequeo de tipos de un codebase real y masivo.

    Eso no es "un poco más rápido". Eso es la diferencia entre lanzar un build y perder el foco, versus lanzar un build y ver el resultado antes de levantar la vista de la pantalla.

    Si trabajas en un proyecto Angular grande — de esos donde el IntelliSense empieza a tartamudear pasados los 200 componentes, como los que armamos en la guía de Angular Signal Forms — este es el tipo de mejora que se siente en el editor todos los días, no solo en el CI.

    Si tu proyecto es de ese tamaño, probablemente ya conoces el dolor de mantener una arquitectura de tipos compartidos entre módulos. En el curso de Angular Moderno trabajamos justo ese tipo de estructura — componentes standalone, signals y una capa de tipos que ahora se va a beneficiar directamente de un compilador que deja de ser el cuello de botella.

    ¿Rompe mi código? Sí, pero no donde crees

    La lógica de inferencia de tipos no cambió. Lo que cambió es que TypeScript 7 convierte en obligatorio todo lo que en 6.0 era opcional o estaba deprecado. Concretamente:

    • strict mode ya no es una opción — es el default forzado.
    • Desaparecen target: es5, downlevelIteration y, como valores de module, AMD, UMD, SystemJS y none (se recomienda esnext o preserve). CommonJS sigue soportado.
    • baseUrl se elimina; los imports relativos tienen que ser explícitos o pasar por paths.
    • Los template literals ahora preservan code points Unicode reales, en vez de partir emojis en pares de surrogates UTF-16. Un detalle pequeño que puede romper tests de snapshots si comparas strings a nivel de caracteres.

    Si tu proyecto usa validación de esquemas con librerías como Zod, strict obligatorio en realidad juega a tu favor: el compilador ahora exige la misma disciplina de tipos que ya deberías estar aplicando en tus schemas. Si todavía no tienes esa disciplina, este es un buen momento para revisar el curso de Zod para TypeScript antes de que strict te obligue a arreglarlo todo de golpe.

    Antes de migrar a TypeScript 7: el checklist que de verdad importa

    Actualizar con npm install -D typescript instala el nuevo tsc nativo sin fricción. El problema nunca es la instalación — es lo que descubres después de instalarlo:

    1. Revisa tu tsconfig.json. Si el archivo vive fuera del directorio de fuentes (algo común en monorepos), ahora tienes que declarar rootDir de forma explícita. Antes el compilador lo inferías; ahora no.
    2. Declara tus @types en el array types. El comportamiento por defecto cambió — si dependes de tipos globales de paquetes como @types/node o @types/jest, sé explícito o vas a ver errores de "no se encuentra el nombre" en símbolos que antes funcionaban solos.
    // tsconfig.json — antes (TypeScript 6.0, inferido)
    {
      "compilerOptions": {
        // rootDir se infería, types no era obligatorio
      }
    }
    
    // tsconfig.json — después (TypeScript 7.0, explícito)
    {
      "compilerOptions": {
        "rootDir": "./src",
        "types": ["node", "jest"]
      }
    }
    
    1. Si tienes JavaScript con JSDoc, revisa el CHANGES.md del proyecto. Patrones como @enum, el operador postfix ! o sintaxis estilo Closure divergen del comportamiento de 6.0. No es una lista larga, pero si tu proyecto tiene archivos .js documentados con JSDoc, vale la pena los cinco minutos de lectura.
    2. Si necesitas convivir con TS 6.0 — por ejemplo, porque una herramienta de tu stack todavía depende de la API interna — instala el paquete @typescript/typescript6, que expone un ejecutable tsc6 en paralelo.

    Nada de esto es dramático. Pero tampoco es un "npm install y ya". Trátalo como tratarías cualquier upgrade de compilador mayor — o como el cambio de Karma a Vitest en Angular 22: en una rama aparte, con CI corriendo antes de tocar main.

    Lo que todavía no puedes hacer

    Aquí está la letra pequeña que casi nadie está mencionando: la API programática estable de TypeScript 7 — la que usan herramientas como ts-morph, plugins de bundlers o el propio Angular Language Service para chequeo de tipos en templates — no llega hasta la versión 7.1. El GA de hoy es para el CLI, para tsc. No para quien construye herramientas sobre el compilador.

    Eso significa que, por ahora, puedes usar TypeScript 7 para el chequeo de proyecto completo desde la línea de comandos y sacarle el speedup en CI hoy mismo. Pero el chequeo dentro de templates de Angular en tu editor va a seguir dependiendo de TypeScript 6.0 hasta que esa API se estabilice. Es una convivencia perfectamente normal, no una incompatibilidad — simplemente no esperes que todo tu tooling salte a la vez.


    Mi consejo, después de quince años viendo migraciones de compiladores salir mal por prisa: no actualices tu proyecto de producción esta semana solo porque salió el anuncio.

    Crea una rama, instala TypeScript 7, corre tu build y tu suite de tipos, y mide tú mismo la diferencia de tiempo antes de tocar main. El speedup es real, pero el checklist de arriba es lo que separa una migración de una tarde de una migración de una semana apagando incendios.

    Si quieres profundizar en arquitecturas TypeScript grandes y cómo estructurarlas para que este tipo de mejoras de compilador realmente se noten, en Dominicode Labs compartimos los proyectos y patrones que uso en clientes reales, actualizados a medida que el ecosistema cambia.


    Preguntas frecuentes

    ¿Debo actualizar mi proyecto a TypeScript 7 ya?

    Para probar y medir, sí — en una rama separada, no en producción directamente. Para producción, primero revisa el checklist de breaking changes (strict obligatorio, rootDir explícito, array types, eliminación de targets legacy) y corre tu CI completo antes de mergear.

    ¿TypeScript 7 rompe mi código actual?

    La lógica de type-checking es estructuralmente idéntica a la de TypeScript 6.0. Si tu proyecto ya compilaba limpio en 6.0 con stableTypeOrdering y sin usar flags deprecados, debería compilar igual. Lo que sí rompe son los defaults: strict obligatorio, sin target: es5, sin módulos amd/umd/systemjs/none (CommonJS sigue soportado) y sin baseUrl.

    ¿Qué es tsgo?

    Es el nombre que tuvo el proyecto de reescritura del compilador de TypeScript en Go durante su fase de beta y builds nightly. En el release final de TypeScript 7.0, ese compilador nativo en Go es tsc — no existe un binario separado llamado tsgo que tengas que invocar.

    ¿Angular ya es compatible con TypeScript 7?

    Parcialmente. Puedes usar TypeScript 7 desde la CLI para el chequeo de tipos de proyecto completo y aprovechar el speedup en builds y CI hoy mismo. El propio anuncio de lanzamiento de Microsoft admite que las herramientas que embeben TypeScript en su propio compilador — como las que dan soporte a templates de Angular — "probablemente" seguirán dependiendo de TypeScript 6.0 hasta que la API programática estable llegue en la 7.1. Angular todavía no ha publicado su propia matriz de compatibilidad para TS 7, así que confírmalo en su documentación oficial antes de tocar el editor de tu equipo.

    ¿Cuándo llega la API programática estable?

    Microsoft la tiene planificada para TypeScript 7.1, no para este GA de 7.0. Si construyes herramientas sobre el compilador (ts-morph, plugins de build, integraciones de linters), tu código seguirá dependiendo de la API de TypeScript 6.0 hasta esa siguiente versión.


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

  • De callbacks a Signals: la reactividad real del frontend

    De callbacks a Signals: la reactividad real del frontend

    Un excliente me escribió hace años, angustiado. Su carrito de compras mostraba 3 artículos en el header, pero el checkout decía que había 5. Los clientes se quejaban en soporte y algunos abandonaban la compra.

    Revisé el código. Puro estilo jQuery: DOM manipulado a mano, evento por evento. Un event listener actualizaba el contador del header. Otro, completamente separado, actualizaba el resumen del checkout. Nadie los había conectado entre sí — y ahí estaba el problema real: cero programación reactiva, cero garantía de que el estado y la interfaz dijeran la misma verdad.

    Cuando alguien hacía clic dos veces seguidas y rápido, un listener terminaba antes que el otro. El total quedaba repartido entre cuatro variables sueltas, cada una con su propia versión de la verdad. Pasé tres horas arreglando algo que debería haberme tomado diez minutos. No porque el bug fuera complejo — porque nada en el código garantizaba que la interfaz reflejara el estado real.

    Llevamos veinte años resolviendo ese mismo problema con herramientas distintas. Primero fueron callbacks manuales sobre el DOM. Luego llegó el Virtual DOM. Ahora, señales. Cada era resolvió lo que la anterior no pudo — y entender por qué importa más que memorizar sintaxis nueva cada dos años.


    Era 1: callbacks manuales y el DOM que se te olvida sincronizar

    En los tiempos de jQuery — y del DOM vanilla antes de eso — la única forma de reaccionar a un evento era escucharlo y mutar el DOM a mano. Tú decidías qué elemento tocar, cuándo y con qué valor.

    Toma el ejemplo clásico: un contador de carrito con tres elementos que dependen del mismo dato.

    let count = 0;
    
    const counterEl = document.querySelector('#counter');
    const totalEl = document.querySelector('#total');
    const shippingMsgEl = document.querySelector('#shipping-msg');
    
    document.querySelector('#add-btn').addEventListener('click', () => {
      count++;
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      shippingMsgEl.textContent = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    });
    
    document.querySelector('#remove-btn').addEventListener('click', () => {
      count = Math.max(0, count - 1);
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      // shippingMsgEl no se actualiza aquí. Nadie lo notó en code review.
    });
    

    Mira el comentario en la última línea. Ese es, casi literal, el bug que revisé en el carrito de mi excliente.

    No es un error de sintaxis — el código compila, pasa QA si nadie prueba el camino de "quitar un producto cuando ya tenías envío gratis". El bug vive en la cabeza del developer: hay que acordarse de tocar los tres elementos en cada handler que mueva ese estado.

    La ventaja de este modelo es real: control total, cero abstracciones, cero curva de aprendizaje. Para un widget aislado — un acordeón, un modal, un tooltip — sigue siendo la opción correcta hoy mismo.

    El problema aparece en cuanto el estado deja de ser trivial:

    • Cada elemento dependiente necesita su propia línea de sincronización, repetida en cada handler que toque ese estado.
    • El estado vive disperso: a veces en el DOM (el.textContent), a veces en variables sueltas, a veces en atributos data-*.
    • Los listeners no se limpian solos. En una SPA que monta y desmonta vistas, cada addEventListener sin su removeEventListener es un memory leak esperando a pasar factura.

    Esto nunca fue un problema de jQuery. Fue un problema de arquitectura: nada en el modelo te obligaba a centralizar el estado ni a declarar sus dependencias. Cada developer inventaba su propia disciplina — y la disciplina, a escala de equipo, no escala.

    Era 2: Virtual DOM y el modelo declarativo

    React cambió la pregunta. En lugar de "¿qué elemento del DOM tengo que tocar?", pasó a ser "¿cómo se ve la UI dado este estado?". Tú describes el resultado final; el framework decide cómo llegar ahí.

    function Counter() {
      const [count, setCount] = useState(0);
      const total = (count * 19.99).toFixed(2);
      const shippingMsg = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    
      return (
        <div>
          <p>{count}</p>
          <p>${total}</p>
          <p>{shippingMsg}</p>
          <button onClick={() => setCount(c => c + 1)}>Añadir</button>
          <button onClick={() => setCount(c => Math.max(0, c - 1))}>Quitar</button>
        </div>
      );
    }
    

    El bug del carrito es estructuralmente imposible aquí. total y shippingMsg se calculan en la misma función, a partir del mismo count, cada vez que el componente se ejecuta. No hay "actualizar" — hay "recalcular todo desde cero", así que no hay forma de que uno se sincronice y el otro se olvide.

    Ahí está la clave del Virtual DOM. React no toca el DOM real en cada cambio. Construye un árbol en memoria — objetos JavaScript planos que describen cómo debería verse la UI — y lo compara contra el árbol anterior. Ese proceso se llama reconciliation, y el algoritmo de comparación es el diffing: detecta qué nodos cambiaron, cuáles se reutilizan, y calcula el mínimo de operaciones para que el DOM real refleje el nuevo árbol. Solo entonces toca el DOM — y solo donde hace falta.

    Es un modelo declarativo y predecible. Pero el coste real no es gratis, y es lo que casi nadie menciona en los tutoriales de introducción: cada cambio de estado re-ejecuta la función completa del componente y, por defecto, la de sus hijos.

    En un árbol de cuarenta componentes anidados, un solo tecleo puede disparar cuarenta re-renders y cuarenta diffs — la mayoría comparando nodos que ni siquiera cambiaron.

    La respuesta del ecosistema fue la memoization: memo(), useMemo(), useCallback(). Son parches necesarios para un problema que el propio modelo introduce: no sabes qué cambió hasta que recalculas y comparas. Memoizar es responsabilidad manual otra vez — la misma que el Virtual DOM prometía eliminar, solo que movida un nivel más arriba en el árbol.

    Era 3: reactividad fina — el grafo en vez del árbol

    Los signals no comparan nada. No hay árbol virtual, no hay diffing, no hay re-render de una función completa. Un signal es una caja que guarda un valor y sabe, con precisión, quién depende de él.

    import { Component, signal, computed, effect } from '@angular/core';
    
    @Component({
      selector: 'app-cart-counter',
      template: `
        <p>{{ count() }}</p>
        <p>${{ total() }}</p>
        <p>{{ shippingMsg() }}</p>
        <button (click)="count.set(count() + 1)">Añadir</button>
        <button (click)="count.set(count() - 1)">Quitar</button>
      `,
    })
    export class CartCounterComponent {
      count = signal(0);
    
      total = computed(() => (this.count() * 19.99).toFixed(2));
    
      shippingMsg = computed(() =>
        this.count() >= 5
          ? '¡Envío gratis!'
          : `Añade ${5 - this.count()} más para envío gratis`
      );
    
      constructor() {
        effect(() => {
          console.log(`Carrito: ${this.count()} items — $${this.total()}`);
        });
      }
    }
    

    Cuando count cambia, Angular no re-ejecuta el componente entero ni reconstruye ningún árbol para comparar. total y shippingMsg ya saben que dependen de count — lo registraron la primera vez que se ejecutaron, al construirse el grafo reactivo. Angular actualiza exactamente el nodo del DOM ligado a cada binding. Nada más se mueve.

    Esto es reactividad fina (fine-grained reactivity): la granularidad de la actualización no es el componente, ni el subárbol — es el binding individual. Angular v22 lleva esto hasta el final siendo zoneless por defecto: ya no depende de Zone.js interceptando cada setTimeout o evento del navegador para saber cuándo revisar cambios. El grafo de signals es la única fuente de verdad sobre qué actualizar y cuándo.

    Angular no inventó este modelo — lo adoptó y lo llevó a producción a escala. Solid.js lo demostró primero, sin Virtual DOM desde el diseño inicial. Svelte llega a un resultado parecido compilando la reactividad en tiempo de build. Los tres coinciden en el mismo diagnóstico: comparar árboles es trabajo evitable si sabes de antemano quién depende de quién.

    Si quieres ver cada primitiva documentada en detalle, la guía oficial de Angular Signals cubre signal(), computed() y effect() con más profundidad de la que cabe en un post.

    Si quieres ver cómo se construye ese grafo de dependencias paso a paso — incluyendo los casos raros donde un effect() se dispara más veces de las que esperas — lo cubrí a fondo en el post sobre el grafo reactivo de Angular Signals.

    En el curso de Angular Moderno construimos este modelo mental desde cero, con proyectos reales donde pasar de Zone.js a zoneless cambia decisiones de arquitectura, no solo de sintaxis.

    Los tres paradigmas, uno al lado del otro

    Modelo mental Cómo detecta cambios Granularidad de la actualización Coste computacional Dónde brilla
    Callbacks (jQuery / DOM imperativo) Tú mutas el DOM a mano, evento por evento No detecta nada — el developer decide cuándo actualizar La que tú programes, elemento por elemento Bajo por operación, alto en mantenimiento y bugs de sincronización Widgets aislados, prototipos, páginas sin estado compartido
    Virtual DOM (React) La UI es una función pura del estado Diffing — compara árbol virtual anterior vs. nuevo Por componente/subárbol, tras re-ejecutar y comparar Re-ejecuta la función de render completa y diffea en cada cambio Apps con estado complejo, equipos grandes, ecosistema maduro
    Signals (Angular, Solid, Svelte) Grafo de dependencias reactivas Suscripción directa — el signal sabe quién lo consume El binding o nodo exacto del DOM que depende del valor Solo se ejecuta lo que realmente cambió UI de alta frecuencia de actualización, listas grandes, apps sensibles a rendimiento

    Por qué la reactividad fina no es una moda

    Cada era resolvió el cuello de botella real de la anterior — no la anterior en abstracto, la anterior en producción.

    Los callbacks resolvieron "cómo reacciono a un evento del usuario". Fue suficiente mientras la UI tenía poco estado compartido. Dejó de serlo en cuanto una sola acción tenía que actualizar cinco sitios distintos de la pantalla.

    El Virtual DOM resolvió "cómo mantengo la UI declarativa sin perder la cordura sincronizando elementos a mano". A cambio, aceptó un coste: recalcular y comparar árboles que, la mayoría de las veces, apenas habían cambiado.

    Signals resuelve el cuello de botella que el Virtual DOM introdujo: cómo evitar recalcular y comparar lo que ya sabías que no había cambiado. No es una versión "más rápida" de React. Es una respuesta distinta a la misma pregunta de fondo: ¿qué es lo mínimo que tengo que actualizar para que la UI diga la verdad?

    Esto no significa que el Virtual DOM esté acabado, ni que debas reescribir tu app de React mañana.

    Significa que si estás arrancando un proyecto hoy, entender este modelo ya no es opcional — es la diferencia entre construir sobre un patrón que resuelve el problema en su raíz o sobre uno que lo parchea con memoization.

    Esta decisión de arquitectura — dónde vive el estado, cómo fluye, qué parte del sistema es responsable de mantenerlo sincronizado con la UI — es exactamente el tipo de decisión que trato en el post sobre Clean Architecture para frontend con IA: la reactividad que elijas no es un detalle de implementación, es una decisión que carga con consecuencias durante años.

    Si vas a construir con signals en producción, en algún momento necesitarás verificar que esos computed() y effect() se comportan como esperas bajo distintos escenarios — eso es justo lo que trabajamos con casos reales en el curso de Testing en Angular con Jest y Testing Library.

    Y si quieres discutir esto con otros developers que están tomando las mismas decisiones ahora mismo, en Dominicode Labs es donde pasa esa conversación cada semana.

    Preguntas frecuentes sobre programación reactiva en el frontend

    ¿Qué es la programación reactiva?

    Es el paradigma en el que la interfaz se actualiza automáticamente cuando cambia el estado del que depende, sin que el desarrollador tenga que sincronizarla a mano evento por evento. Los tres modelos de este post — callbacks, Virtual DOM y signals — son formas distintas de resolver ese mismo problema, con más o menos reactividad real incorporada al framework.

    ¿El Virtual DOM está muerto?

    No. Sigue siendo el modelo dominante en producción — React tiene el ecosistema, el talento disponible y millones de líneas de código funcionando con él hoy. Lo que cambió es que ya no es la única opción seria para UI compleja: Signals, Solid.js y Svelte demuestran que el diffing es una solución al problema, no la única posible.

    ¿Los Signals reemplazan a React?

    No en el sentido de que React vaya a desaparecer. Angular con Signals, Solid.js y Svelte son alternativas con un modelo distinto, no reemplazos del ecosistema React. Sí es cierto que la presión competitiva ya empujó a React hacia herramientas como React Compiler, que intenta automatizar la memoization que antes hacías a mano.

    ¿Qué es la reactividad fina (fine-grained reactivity)?

    Es un modelo donde cada pieza de estado (signal) mantiene una lista explícita de quién depende de ella — otros signals derivados (computed) o efectos secundarios (effect). Cuando el valor cambia, solo se re-ejecuta lo que está suscrito a ese valor específico, sin comparar árboles ni recalcular lo que no depende de ese dato.

    ¿Angular usa Virtual DOM?

    No, y nunca lo usó. Angular usaba Zone.js y un mecanismo de change detection basado en recorrer el árbol de componentes buscando cambios. Con Signals y el modo zoneless, por defecto desde Angular v22, Angular elimina también ese recorrido: el grafo de signals le dice exactamente qué actualizar, sin Zone.js y sin diffing.

    ¿Debo migrar mi app de React a Signals?

    No si tu app funciona bien y el equipo domina React. La reactividad fina brilla en escenarios concretos: dashboards con actualizaciones muy frecuentes, listas grandes, apps donde el rendimiento de render es un cuello de botella medido, no sospechado. Si estás empezando un proyecto nuevo, sí vale la pena evaluar Angular v22 con Signals como opción seria.


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

  • Angular Signal Forms: por qué reemplaza a Reactive Forms

    Angular Signal Forms: por qué reemplaza a Reactive Forms

    Hace un par de semanas revisé un pull request de un formulario de checkout. Línea 40: const email = form.value.email as string. Le pregunté al autor por qué el cast. Silencio de dos segundos. Luego: "porque si no, TypeScript se queja".

    Ese "se queja" es el síntoma de un problema real que Angular Signal Forms existe justamente para eliminar. En Reactive Forms, form.value.email no es string. Es string | null. Angular lo tipa así porque reset() limpia los valores a null, y el compilador no tiene forma de saber si ya hiciste reset o no — así que cada lectura del formulario viene con un cast disfrazado de costumbre.

    Quince años escribiendo formularios en Angular. Quince años haciendo ese cast sin cuestionarlo. Angular Signal Forms, el nuevo sistema de formularios que llega con Angular v22, es la primera vez que veo una solución que no es un parche — es un cambio de premisa.


    Los 3 problemas reales de Reactive Forms

    Reactive Forms no es "malo" — lleva años en producción y funciona. Pero arrastra tres problemas estructurales que nacen de la misma raíz.

    1. El tipado es una mentira

    Por defecto, cada FormControl tiene tipo T | null, incluso cuando tu modelo de dominio nunca admite null en ese campo.

    form = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', Validators.required],
    });
    // form.value.email es string | null — no string
    

    El tipo del formulario nunca coincide con el tipo del dominio. Terminas casteando con as, o duplicando la interfaz a mano con un NonNullable<T> que mantienes sincronizado manualmente. Ninguna de las dos opciones es tipado real — son formas distintas de callar al compilador.

    2. El formulario es la fuente de verdad, no el modelo

    En Reactive Forms, el estado vive dentro del FormGroup. Tu modelo de dominio es una consecuencia que extraes cuando lo necesitas, no el origen de la verdad.

    Para extraerlo tienes cuatro caminos distintos, todos manuales: patchValue(), getRawValue(), reset() con un objeto de valores, o .get('email')?.setValue(x) campo por campo. Cuatro formas de sincronizar el mismo dato, cada una esperando a que se te olvide usarla en el momento correcto.

    3. ControlValueAccessor es la interfaz más odiada de Angular

    Cualquiera que haya construido un input custom en Angular conoce el ritual: implementar writeValue(), registerOnChange(), registerOnTouched(), setDisabledState(), y registrar un provider NG_VALUE_ACCESSOR en el componente.

    providers: [{
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CustomInputComponent),
      multi: true,
    }]
    

    Son cerca de 20 líneas de boilerplate imperativo por cada control. Sin genéricos reales, sin signals, sin forma de que el compilador te ayude si te equivocas en el tipo del valor que emites.


    Signal Forms: el reinicio conceptual

    Aquí está el punto que más se malinterpreta: Signal Forms no es "Reactive Forms 2". No es una versión mejorada del mismo paradigma con signals encima. Es empezar de cero con una premisa distinta.

    En Reactive Forms, el formulario manda y el modelo es una extracción. En Signal Forms, el modelo es un signal() normal y el formulario es una vista reactiva de ese signal. Cuando el usuario escribe en un input, el signal se actualiza. Cuando tú cambias el signal desde código, el formulario se actualiza solo. Ya no hay cuatro formas de sincronizar — solo tocas el signal.

    Es el mismo giro conceptual que ya vimos con la reactividad fina de signals frente a callbacks y suscripciones manuales: una fuente de verdad única, y todo lo demás reacciona a ella. Signal Forms aplica esa misma idea al dominio de los formularios.

    @Component({
      imports: [FormField, FormRoot],
      template: `
        <form [formRoot]="loginForm">
          <input [formField]="loginForm.email" type="email" />
          @if (loginForm.email().invalid() && loginForm.email().touched()) {
            <span>Email inválido</span>
          }
          <input [formField]="loginForm.password" type="password" />
          <button type="submit">Entrar</button>
        </form>
      `
    })
    export class LoginComponent {
      readonly loginModel = signal({ email: '', password: '' });
      readonly loginForm = form(this.loginModel, loginSchema);
    }
    

    Compara esto con el FormGroup de arriba. No hay fb.group(), no hay Validators.email como clase estática, no hay .get('email'). Hay un signal (loginModel) y una función (form()) que lo envuelve.

    form(), FieldTree y FieldState

    form(model, schema?) recibe tu modelo — y aquí hay una regla estricta: el modelo debe ser un WritableSignal<T>, un signal() normal y escribible. Si le pasas un computed(), no compila. No es un "formulario de solo lectura" — es directamente un error de tipos, porque Signal Forms necesita poder escribir de vuelta en el modelo cuando el usuario interactúa.

    form() devuelve un FieldTree<T>. Accedes a cada campo por dot-notation: loginForm.email es un nodo de ese árbol. Al invocarlo como función — loginForm.email() — obtienes un FieldState, que expone todo como signals: value() (un WritableSignal), dirty(), touched(), invalid(), valid(), errors(), pending(), disabled(), readonly(), required(), hidden(). Y métodos como markAsTouched(), markAsDirty(), reset(). No existe ningún tipo llamado "FieldNode" — es FieldTree para la estructura y FieldState para el estado de un campo concreto.

    Sin módulos, solo directivas standalone

    No existe ningún FormsSignalsModule. Las dos piezas que necesitas son directivas standalone: FormField (aporta [formField], va en cada input) y FormRoot (aporta [formRoot], va en el <form>). Se importan una por una: imports: [FormField, FormRoot].

    Signal Forms vive en @angular/forms/signals, ya incluido si tienes @angular/forms en v21 o superior. No necesitas ningún provider global adicional en app.config.ts — es standalone de principio a fin.

    [formRoot] es la pieza nueva de v21.2/v22 que más simplifica el día a día: aplica novalidate automáticamente, intercepta el submit nativo, hace preventDefault y dispara el envío del FieldTree sin que tengas que escribir un (ngSubmit) manual. El estilo clásico de <form> + (submit) sigue funcionando si lo prefieres, pero el camino recomendado en Signal Forms es el declarativo con [formRoot].

    Esto es exactamente el tipo de arquitectura que estamos actualizando a v22 en el curso de Angular Moderno — el objetivo no es memorizar la API nueva, sino entender por qué el modelo manda ahora en vez del framework.


    Validación con schema declarativo

    La validación se declara aparte, con schema<T>((path) => { ... }). La convención oficial nombra el parámetro path — no f, no form, no otro nombre. Vale la pena respetarla porque es lo que vas a ver en toda la documentación y en el código de otros equipos.

    const loginSchema = schema<LoginForm>((path) => {
      required(path.email);
      email(path.email);
      required(path.password);
      minLength(path.password, 8);
    });
    

    Los validadores built-in siguen todos el mismo patrón, con el path primero: required(path.campo), email(path.campo), minLength(path.campo, n), maxLength(path.campo, n), min(path.campo, n), max(path.campo, n), pattern(path.campo, regex).

    Si escribes un validador custom, hay una regla que no es opcional: debe devolver undefined en caso de éxito, nunca null. Es la "regla del undefined" — un detalle pequeño que rompe la validación entera si lo pasas por alto viniendo de la costumbre de Reactive Forms, donde null era la señal de "todo bien".


    Controles custom sin ControlValueAccessor

    Los controles custom en Signal Forms se implementan con la interfaz FormValueControl<T>, que reemplaza a ControlValueAccessor con un solo miembro obligatorio: value, como ModelSignal<T> creado con model(). Todo lo demás — disabled, errors, touched, required — son input() opcionales.

    Es aquí donde Signal Forms cambia más el día a día si construyes design systems o librerías de componentes internas. ControlValueAccessor exige implementar cuatro métodos y registrar un provider, como vimos arriba.

    export class CustomInputComponent implements FormValueControl<string> {
      readonly value = model('');                      // único miembro REQUERIDO
      readonly disabled = input(false);                 // opcional
      readonly errors = input<ValidationError[]>([]);   // opcional
    }
    

    Eso es todo. Sin forwardRef, sin NG_VALUE_ACCESSOR, sin registerOnChange guardando una función en una propiedad privada. El compilador conoce el tipo real del valor porque model<string>() lo declara, no porque tú lo prometas en un comentario.


    El estado real de madurez en v22 — sin exagerar

    Signal Forms es @experimental desde Angular v21. En v22, la API core madura significativamente — el comportamiento de form(), [formField], schema() y los validadores built-in se estabiliza. Pero el marcado @experimental puede seguir presente en algunos subconjuntos de la API. Funciones más avanzadas, como validateAsync() o compatForm(), pueden tener cambios menores antes de la estabilización oficial completa.

    Este es exactamente el tipo de dato que el ecosistema Angular audita de cerca, así que voy a ser preciso: "ya es estable, úsalo sin pensar" sería una simplificación que no te sirve para decidir en producción. Puedes verificar el estado exacto de cada API en la documentación oficial de formularios de Angular.

    En la práctica, esto se traduce en tres escenarios:

    Escenario Recomendación
    Código nuevo en tu proyecto propio o side project Úsalo sin reservas
    Código nuevo en un proyecto de empresa Evalúa el riesgo, documenta la decisión con el equipo
    Migrar formularios legacy críticos Espera a la estabilización completa

    Si decides adoptarlo ahora, blindarlo con tests importa más que nunca — y el cambio de paradigma en testing es tan grande como en los formularios mismos. Testear un FieldTree no es testear un FormGroup: lees el value() directamente como signal, sin suscribirte a valueChanges ni esperar un ciclo extra de detección de cambios para que el observable propague. Es exactamente el tipo de ajuste que cubrimos en el curso de Testing en Angular actualizado a este modelo.


    Reactive Forms vs Signal Forms — comparativa

    Reactive Forms Signal Forms
    Tipado T | null por defecto, casts frecuentes El tipo del modelo es el tipo real, sin null fantasma
    Fuente de verdad El FormGroup — el modelo se extrae El signal() del modelo — el form es una vista
    Controles custom ControlValueAccessor, ~20 líneas, sin genéricos FormValueControl<T>, solo value requerido
    Boilerplate Alto: provider, 4 métodos, forwardRef Bajo: directivas standalone, sin módulo
    Estado de madurez (v22) Estable, años en producción @experimental, API core estable en comportamiento

    La tesis

    Signal Forms no es una feature más de la lista de novedades de v22. Es la misma premisa que ya cambió cómo pensamos la reactividad con signal(), computed() y effect(), aplicada ahora al dominio de los formularios: el modelo manda, el framework refleja.

    Angular lleva desde v16 moviéndose hacia signals-first, y Signal Forms es la pieza que faltaba para que esa filosofía cubriera también la parte más tediosa de cualquier aplicación real. Si quieres ver dónde encaja dentro del resto de cambios de esta versión, lo cubrimos en el repaso de novedades de Angular v22.

    Lo que puedes hacer hoy: si tienes un side project o un proyecto nuevo sin presión de legacy, monta el próximo formulario con Signal Forms. No esperes a que el @experimental desaparezca del todo — la API core ya se comporta como se va a comportar. Si estás en un proyecto de empresa, documenta la decisión y evalúa el riesgo con tu equipo antes de migrar nada crítico.

    En Dominicode Labs estamos ya construyendo con Signal Forms en los proyectos de la comunidad — si quieres ver los patrones reales, sin el filtro del ejemplo de documentación, es ahí donde está pasando.


    Preguntas frecuentes sobre Angular Signal Forms

    ¿Qué es Angular Signal Forms?

    Es el nuevo sistema de formularios de Angular, disponible desde v21 como @experimental y madurando en v22. En vez de que el formulario sea la fuente de verdad, el modelo es un signal() normal y el formulario es una vista reactiva de ese signal. Se construye con form(model, schema), que devuelve un FieldTree, y se conecta al template con las directivas standalone [formField] y [formRoot].

    ¿Signal Forms reemplaza a Reactive Forms?

    Conceptualmente sí, pero no de un día para otro. Reactive Forms sigue siendo estable y soportado. Signal Forms no es "Reactive Forms con signals encima" — es un sistema construido desde cero sobre la premisa de que el modelo manda. Para proyectos nuevos, es la dirección a seguir. Para formularios existentes en producción, migrarlos no es urgente todavía.

    ¿Puedo usar Signal Forms en producción en Angular 22?

    Depende del contexto. En tu proyecto propio, sí, sin reservas — la API core (form(), [formField], schema(), validadores built-in) se estabiliza en comportamiento en v22. En un proyecto de empresa, evalúa el riesgo y documenta la decisión, porque el marcado @experimental puede seguir en algunos subconjuntos de la API. Para migrar formularios legacy críticos, espera a la estabilización oficial completa.

    ¿Necesito importar un módulo para usar Signal Forms?

    No. No existe ningún FormsSignalsModule. Las directivas FormField y FormRoot son standalone y se importan directamente en el array imports del componente. Tampoco necesitas ningún provider adicional en app.config.ts — solo tener @angular/forms en v21 o superior.

    ¿Cómo se hacen controles de formulario custom en Signal Forms?

    Con la interfaz FormValueControl<T>, que reemplaza a ControlValueAccessor. Solo necesitas un miembro obligatorio: value como ModelSignal<T> creado con model(). Propiedades como disabled, errors, touched o required son input() opcionales, sin necesidad de provider ni forwardRef.


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

  • MCP Server en TypeScript: conecta Claude Code con cualquier API

    MCP Server en TypeScript: conecta Claude Code con cualquier API

    claude mcp add –transport stdio github-issues — node /ruta/absoluta/build/index.js

    Para todos los proyectos (ámbito global del usuario)

    claude mcp add –scope user –transport stdio github-issues — node /ruta/absoluta/build/index.js

    
    Verifica que Claude Code lo reconoce:
    
    ```bash
    claude mcp list
    

    Deberías ver github-issues en el listado con estado Pending approval. Una vez que lo apruebes desde Claude Code, pasará a connected.


    Cómo probarlo desde una sesión de Claude Code

    Abre Claude Code en cualquier directorio y escribe:

    Lista los issues abiertos del repo microsoft/vscode
    

    Claude detecta que tiene acceso al tool list_issues, lo llama con { owner: "microsoft", repo: "vscode", state: "open" }, y devuelve la lista formateada directamente en el chat.

    Sin salir. Sin copiar y pegar. Sin fricción.

    Para repos privados, añade tu token de GitHub como variable de entorno antes de registrar el server:

    # En el comando de registro pasa el env directamente
    claude mcp add --transport stdio github-issues --env GITHUB_TOKEN=ghp_xxx -- node /ruta/absoluta/build/index.js
    

    Y en el código, descomenta la línea Authorization: Bearer ${process.env.GITHUB_TOKEN}.


    Ir más allá: cuándo crear tu propio MCP server

    Esta es la pregunta real. El ecosistema de MCP servers públicos ya tiene integraciones para GitHub, Slack, Notion, bases de datos, filesystems, y decenas más. No construyas lo que ya existe.

    Crea tu propio server cuando:

    1. Tienes una API interna que nadie más va a integrar
    2. Necesitas transformar o filtrar datos antes de que lleguen al modelo — la lógica de negocio importa
    3. Quieres controlar exactamente qué puede hacer Claude y qué no en tu entorno
    4. Estás construyendo un producto y necesitas que Claude interactúe con él de forma programática

    El patrón que acabas de aprender escala sin cambios. Añadir un tool nuevo es copiar el bloque del handler y registrarlo en ListToolsRequestSchema. Añadir autenticación es una cabecera. Añadir caché es un Map en memoria.

    El scaffold es siempre el mismo. Lo que cambia es la lógica de negocio de cada tool.

    Si quieres profundizar en este modelo de trabajo — construir con IA de forma estructurada, con specs, con MCP servers propios, con agentes que hacen trabajo real — en el curso Construye con IA: De la Idea al Producto con Claude Code trabajamos exactamente este flujo. Desde la idea hasta tener algo en producción.


    FAQ

    ¿Necesito compilar TypeScript para usar el server? ¿No puedo usar tsx directamente?

    Puedes. Para desarrollo local, tsx src/index.ts funciona. Para registrar en Claude Code de forma estable, compilar a JS es más fiable porque no dependes de que tsx esté instalado globalmente. En el comando claude mcp add puedes usar npx tsx si prefieres:

    claude mcp add --transport stdio github-issues -- npx tsx /ruta/src/index.ts
    

    ¿Cuál es la diferencia entre stdio y HTTP como transporte?

    StdioServerTransport es el modo local: Claude Code lanza tu server como proceso hijo y se comunica por stdin/stdout. Es el modo más simple y suficiente para tools personales o de equipo. El transporte HTTP (Streamable HTTP) es para servers remotos que quieres exponer como servicio — por ejemplo, si construyes un MCP server para tu empresa y lo despliegas en un servidor.

    ¿Mis tools pueden leer archivos del sistema o ejecutar comandos?

    Sí, un MCP server tiene acceso completo al sistema donde se ejecuta. Puede leer archivos con fs, ejecutar procesos con child_process, hacer peticiones de red. Eso también es la responsabilidad: el server corre con los permisos del usuario que lo lanza, así que diseña los tools con cuidado y no expongas capacidades destructivas sin confirmación.

    ¿Funciona con Claude Desktop o solo con Claude Code?

    Funciona con cualquier cliente MCP compatible. Claude Desktop usa claude_desktop_config.json en lugar de claude mcp add, pero el server es exactamente el mismo. También es compatible con Cursor, Continue, y cualquier cliente que implemente el protocolo. Ese es el punto de MCP: escribes el server una vez, lo consumes desde donde quieras.

    ¿Puedo añadir varios tools al mismo server?

    Sí, y es lo recomendable cuando los tools comparten contexto. Un server de GitHub podría tener list_issues, create_issue, list_pull_requests y get_file_content en el mismo proceso. Cada tool se declara en el handler de ListToolsRequestSchema y se implementa en el bloque if correspondiente dentro de CallToolRequestSchema.


    Conclusión

    Ya sabes cómo funciona MCP, qué son los tres primitivos, y tienes un server real funcionando que conecta Claude Code con la API de GitHub. El siguiente paso es obvio: sustituye la llamada a GitHub por la API que necesites tú.

    Si estás construyendo flujos de trabajo con agentes IA y quieres ir más allá de los MCP servers públicos, en Dominicode Labs publicamos proyectos completos, code reviews y recursos exclusivos para developers que construyen con IA en serio.

    Para entender cómo Claude Code orquesta tools, sub-agentes y contexto dentro de una sesión, lee primero la introducción a Claude Code que publiqué aquí — es el punto de entrada que te va a dar el marco conceptual completo.


    Bezael Pérez — Developer senior, fundador de Dominicode. 15+ años construyendo software. Ahora construyendo con IA.

  • NestJS + Vercel AI SDK: backend streaming IA en producción

    NestJS + Vercel AI SDK: backend streaming IA en producción

    ANTHROPIC_API_KEY=sk-ant-xxxxxxxx

    
    En `app.module.ts`, registra `ConfigModule`:
    
    ```typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { AiModule } from './ai/ai.module';
    
    @Module({
      imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        AiModule,
      ],
    })
    export class AppModule {}
    

    isGlobal: true significa que ConfigService está disponible en todos los módulos sin importarlo individualmente. Práctico.


    La estructura del AiModule

    Antes de escribir código, la estructura:

    src/
      ai/
        ai.module.ts
        ai.controller.ts
        ai.service.ts
        dto/
          chat.dto.ts
    

    Cuatro archivos. Eso es todo lo que necesita un endpoint de streaming limpio.


    Paso 1: El DTO de validación

    El primer punto de defensa es el DTO. Define el contrato del request:

    // src/ai/dto/chat.dto.ts
    import { IsArray, IsIn, IsString, ValidateNested, ArrayMinSize } from 'class-validator';
    import { Type } from 'class-transformer';
    
    export class ChatMessageDto {
      @IsIn(['user', 'assistant', 'system'])
      role: 'user' | 'assistant' | 'system';
    
      @IsString()
      content: string;
    }
    
    export class ChatRequestDto {
      @IsArray()
      @ArrayMinSize(1)
      @ValidateNested({ each: true })
      @Type(() => ChatMessageDto)
      messages: ChatMessageDto[];
    }
    

    @ValidateNested({ each: true }) valida cada elemento del array individualmente. Si el frontend manda un mensaje con role: 'hacker' o sin content, el request rebota antes de tocar el servicio.

    Para que ValidationPipe funcione globalmente, añádelo en main.ts:

    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { ValidationPipe } from '@nestjs/common';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = NestFactory.create(AppModule);
    
      app.useGlobalPipes(new ValidationPipe({
        transform: true,
        whitelist: true,    // elimina propiedades no declaradas en el DTO
        forbidNonWhitelisted: true,
      }));
    
      // CORS para el frontend Angular en desarrollo
      app.enableCors({
        origin: process.env.FRONTEND_URL ?? 'http://localhost:4200',
        methods: ['POST', 'OPTIONS'],
      });
    
      await app.listen(process.env.PORT ?? 3000);
    }
    
    bootstrap();
    

    whitelist: true es especialmente importante aquí: elimina cualquier campo del body que no esté declarado en el DTO. Si alguien intenta inyectar propiedades extra en el request, NestJS las ignora antes de que lleguen al servicio.


    Paso 2: El AiService

    El servicio encapsula toda la lógica de llamada al modelo. El controlador no sabe qué modelo usamos ni cómo se configura — solo llama al servicio y recibe el stream.

    // src/ai/ai.service.ts
    import { Injectable } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { streamText, CoreMessage } from 'ai';
    import { createAnthropic } from '@ai-sdk/anthropic';
    
    @Injectable()
    export class AiService {
      private readonly anthropic;
    
      constructor(private readonly config: ConfigService) {
        this.anthropic = createAnthropic({
          apiKey: this.config.getOrThrow<string>('ANTHROPIC_API_KEY'),
        });
      }
    
      streamChat(messages: CoreMessage[]) {
        return streamText({
          model: this.anthropic('claude-sonnet-4-6'),
          system: `Eres un asistente técnico especializado en desarrollo de software.
    Responde en español de forma concisa y directa.
    Si el usuario pregunta sobre código, incluye ejemplos concretos.`,
          messages,
          maxTokens: 1024,
        });
      }
    }
    

    Dos decisiones importantes aquí:

    createAnthropic({ apiKey }) en el constructor — el cliente de Anthropic se crea una sola vez cuando NestJS instancia el servicio. No se recrea en cada petición. Eso evita overhead innecesario.

    config.getOrThrow<string>('ANTHROPIC_API_KEY') — si la variable de entorno no existe, la app falla en el arranque con un error claro en lugar de fallar silenciosamente en el primer request. Fail fast.

    maxTokens: 1024 es un límite defensivo. Sin él, un usuario puede hacer una pregunta que genere una respuesta de 8.000 tokens, multiplicando el costo por 8. Ajusta según tu caso de uso.


    Paso 3: El AiController con streaming

    El controlador es donde ocurre la magia del streaming. La clave está en cómo NestJS maneja la respuesta HTTP nativa:

    // src/ai/ai.controller.ts
    import {
      Controller,
      Post,
      Body,
      Res,
      HttpCode,
      HttpStatus,
    } from '@nestjs/common';
    import { Response } from 'express';
    import { AiService } from './ai.service';
    import { ChatRequestDto } from './dto/chat.dto';
    import { CoreMessage } from 'ai';
    
    @Controller('api')
    export class AiController {
      constructor(private readonly aiService: AiService) {}
    
      @Post('chat')
      @HttpCode(HttpStatus.OK)
      async chat(
        @Body() body: ChatRequestDto,
        @Res() res: Response,
      ): Promise<void> {
        const messages = body.messages as CoreMessage[];
    
        const result = this.aiService.streamChat(messages);
    
        // toUIMessageStreamResponse() genera una Response Web estándar
        // con el protocolo SSE del AI SDK
        const streamResponse = result.toUIMessageStreamResponse();
    
        // Propagamos los headers del AI SDK a la respuesta de Express
        streamResponse.headers.forEach((value, key) => {
          res.setHeader(key, value);
        });
    
        res.status(streamResponse.status);
    
        // Volcamos el body del ReadableStream a la respuesta de Express
        if (streamResponse.body) {
          const reader = streamResponse.body.getReader();
    
          const pump = async () => {
            while (true) {
              const { done, value } = await reader.read();
              if (done) {
                res.end();
                break;
              }
              res.write(value);
            }
          };
    
          pump().catch((err) => {
            console.error('[AiController] Error en stream:', err);
            if (!res.headersSent) {
              res.status(500).json({ error: 'Error interno del stream' });
            } else {
              res.end();
            }
          });
        } else {
          res.status(500).json({ error: 'No se pudo iniciar el stream' });
        }
      }
    }
    

    ¿Por qué este patrón de pump manual en lugar de pipe()?

    toUIMessageStreamResponse() devuelve una Response Web estándar (la del spec WHATWG), no un stream de Node.js. Express trabaja con streams de Node.js. El pump manual convierte uno en el otro sin dependencias adicionales. Es verboso pero explícito — sabes exactamente qué hace cada línea.

    El bloque catch en el pump gestiona dos escenarios: si el error ocurre antes de enviar headers, devuelve un 500 con JSON. Si ocurre después (cuando el stream ya está activo), llama a res.end() para cerrar la conexión limpiamente. Sin este manejo, el cliente se quedaría esperando indefinidamente.


    Paso 4: El AiModule

    El módulo agrupa las tres piezas:

    // src/ai/ai.module.ts
    import { Module } from '@nestjs/common';
    import { AiController } from './ai.controller';
    import { AiService } from './ai.service';
    
    @Module({
      controllers: [AiController],
      providers: [AiService],
      exports: [AiService], // por si otros módulos necesitan AiService
    })
    export class AiModule {}
    

    Exportar AiService es una decisión de diseño: si en el futuro un módulo de AgentsModule o DocumentModule necesita llamar al modelo, importan AiModule y tienen el servicio disponible sin duplicar configuración.


    Rate limiting: el paso que nadie incluye

    Sin rate limiting, un solo usuario puede vaciar tu cuota de Anthropic en minutos. NestJS tiene @nestjs/throttler para esto:

    npm install @nestjs/throttler
    

    Configúralo en AppModule:

    // src/app.module.ts
    import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
    import { APP_GUARD } from '@nestjs/core';
    
    @Module({
      imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        ThrottlerModule.forRoot([{
          name: 'short',
          ttl: 60_000,   // 1 minuto en ms
          limit: 10,     // máximo 10 requests por minuto por IP
        }]),
        AiModule,
      ],
      providers: [
        {
          provide: APP_GUARD,
          useClass: ThrottlerGuard,
        },
      ],
    })
    export class AppModule {}
    

    10 requests por minuto por IP es un límite conservador para un chat. En producción, ajusta según el plan de Anthropic que tengas y el perfil de uso esperado. Si tus usuarios son developers que mandan snippets de código largos, 10 puede ser demasiado restrictivo. Si es un chat de soporte con usuarios anónimos, puede ser demasiado permisivo.

    ThrottlerGuard como APP_GUARD aplica el límite a todos los endpoints automáticamente. Si quieres excluir algunos endpoints del límite, usa el decorador @SkipThrottle() en el controlador correspondiente.


    Conectar con el frontend Angular

    Este backend está diseñado para ser el complemento del post Angular v22 + Vercel AI SDK: streaming de IA en tu app en 20 minutos.

    El frontend Angular usa fetch nativo con ReadableStream. El cambio que necesitas en el componente Angular es mínimo: actualizar la URL del endpoint del servidor Bun del post anterior (típicamente en el puerto 4000) a http://localhost:3000/api/chat de este servidor NestJS. El contrato del API no cambia — misma ruta, mismo formato de mensajes.

    La diferencia está en el protocolo de stream. El servidor Bun del post anterior usa toTextStreamResponse(), que devuelve texto plano. Este NestJS usa toUIMessageStreamResponse(), que usa el protocolo SSE estructurado del AI SDK. Para consumir este protocolo desde Angular sin la librería useChat de React, el componente Angular necesita parsear los chunks SSE en lugar de concatenarlos directamente.

    Si ya tienes el frontend del post anterior y quieres migrar a este backend sin tocar el componente, cambia en AiService.streamChat() el retorno a toTextStreamResponse():

    // AiService — variante compatible con el componente Angular del post anterior
    streamChat(messages: CoreMessage[]) {
      return streamText({
        model: this.anthropic('claude-sonnet-4-6'),
        system: 'Eres un asistente técnico...',
        messages,
        maxTokens: 1024,
      });
      // En el controlador usar toTextStreamResponse() en vez de toUIMessageStreamResponse()
    }
    

    Y en el controlador, sustituye result.toUIMessageStreamResponse() por result.toTextStreamResponse(). El componente Angular del post anterior funciona sin cambios.

    La versión con toUIMessageStreamResponse() es la recomendada para proyectos nuevos porque soporta tool calls, metadatos de uso de tokens, y datos personalizados dentro del mismo stream — funcionalidades que toTextStreamResponse() no puede transmitir.

    Característica toUIMessageStreamResponse() toTextStreamResponse()
    Protocolo AI SDK SSE estructurado Texto plano
    Tool calls
    Metadatos de tokens
    Compatible con useChat
    Parsing manual en cliente Necesario sin useChat No necesario
    Cuándo usarlo Proyectos nuevos Compatibilidad con cliente simple

    Manejo de errores: más allá del try/catch

    El error handling que ya tenemos en el pump del controlador cubre los fallos en el stream activo. Pero hay errores que ocurren antes del stream — cuando la API de Anthropic devuelve un 429 (rate limit) o un 500:

    // src/ai/ai.controller.ts — versión con manejo de errores completo
    import { APICallError } from 'ai';
    
    @Post('chat')
    @HttpCode(HttpStatus.OK)
    async chat(
      @Body() body: ChatRequestDto,
      @Res() res: Response,
    ): Promise<void> {
      try {
        const messages = body.messages as CoreMessage[];
        const result = this.aiService.streamChat(messages);
        const streamResponse = result.toUIMessageStreamResponse();
    
        streamResponse.headers.forEach((value, key) => {
          res.setHeader(key, value);
        });
        res.status(streamResponse.status);
    
        if (streamResponse.body) {
          const reader = streamResponse.body.getReader();
    
          const pump = async () => {
            while (true) {
              const { done, value } = await reader.read();
              if (done) { res.end(); break; }
              res.write(value);
            }
          };
    
          await pump();
        }
      } catch (error) {
        if (APICallError.isInstance(error)) {
          // Error de la API del LLM (429, 500, etc.)
          console.error('[AiController] Error API LLM:', error.message, error.statusCode);
    
          if (!res.headersSent) {
            const statusCode = error.statusCode === 429 ? 429 : 502;
            res.status(statusCode).json({
              error: error.statusCode === 429
                ? 'Demasiadas peticiones al modelo. Inténtalo en unos segundos.'
                : 'Error al conectar con el modelo de IA.',
            });
          } else {
            res.end();
          }
        } else {
          console.error('[AiController] Error inesperado:', error);
          if (!res.headersSent) {
            res.status(500).json({ error: 'Error interno del servidor.' });
          } else {
            res.end();
          }
        }
      }
    }
    

    APICallError.isInstance(error) es el type guard del AI SDK para distinguir errores de la API del LLM de errores genéricos. Útil para devolver mensajes de error específicos al cliente sin exponer detalles internos.


    Ejecutar el servidor

    # Desarrollo con hot reload
    npm run start:dev
    
    # Producción
    npm run build && npm run start:prod
    

    El servidor levanta en http://localhost:3000. Prueba el endpoint:

    curl -X POST http://localhost:3000/api/chat \
      -H "Content-Type: application/json" \
      -d '{"messages": [{"role": "user", "content": "Qué es NestJS en una frase"}]}' \
      --no-buffer
    

    Verás los chunks SSE llegar en tiempo real en la terminal. Eso confirma que el streaming funciona.


    El AiModule en producción: qué añadir después

    Lo que hemos construido es una base sólida. En un entorno de producción real, los siguientes pasos son:

    1. Autenticación. Añadir un AuthGuard de JWT al endpoint chat para que solo usuarios autenticados consuman tokens. Sin esto, cualquiera con la URL puede vaciar tu cuota.

    2. Logging estructurado. Usar @nestjs/winston o Pino para loguear cada request con userId, messageCount, y tokensUsed. El AI SDK expone usage en el stream — puedes capturarlo en el onFinish callback de streamText.

    3. Persistencia del historial. El backend actual es stateless — el historial viene del cliente en cada request. En producción con usuarios autenticados, guarda el historial en base de datos y envía solo el conversationId desde el frontend. El servidor reconstruye el historial antes de llamar al modelo.

    4. Selección de modelo por request. Si tu app da a los usuarios la opción de elegir entre Claude Sonnet y Claude Haiku (más barato), añade un campo model al DTO y pásalo al servicio. La abstracción del AI SDK hace que el cambio sea trivial.

    Si quieres profundizar en este tipo de decisiones de arquitectura — cómo estructurar un producto completo con IA desde la idea hasta producción — en el curso Construye con IA: de la idea al producto con Claude Code lo vemos con proyectos reales, no con demos de laboratorio.


    FAQ

    ¿Puedo usar este módulo con Fastify en lugar de Express?

    Sí, pero el pump manual del controlador cambia. Fastify usa Reply en lugar de Response de Express, y el método para escribir chunks es reply.raw.write(). El @Res() res: Response del controlador funcionará si configuras passThrough: true en el decorador: @Res({ passThrough: false }). La lógica del pump en sí no cambia — solo los métodos de la respuesta.

    ¿El rate limiting con ThrottlerGuard funciona bien detrás de un proxy o load balancer?

    Por defecto, ThrottlerGuard usa la IP del request. Si tu app está detrás de un proxy (Nginx, Cloudflare, etc.), la IP será siempre la del proxy. Configura ThrottlerModule con throttlers y usa ThrottlerGuard extendido que lea X-Forwarded-For. Alternativamente, delega el rate limiting al proxy — Nginx tiene limit_req_zone para esto.

    ¿Cómo evito que el stream consuma tokens si el cliente desconecta?

    streamText del AI SDK no cancela automáticamente la petición a Anthropic cuando el cliente cierra la conexión HTTP. Para implementar cancelación, pasa un AbortSignal a streamText:

    streamChat(messages: CoreMessage[], signal?: AbortSignal) {
      return streamText({
        model: this.anthropic('claude-sonnet-4-6'),
        messages,
        abortSignal: signal,
      });
    }
    

    En el controlador, escucha el evento close de la respuesta y llama a abortController.abort(). Esto cancela la llamada a la API antes de que el modelo termine de generar.

    ¿Puedo usar @ai-sdk/openai o @ai-sdk/google en lugar de Anthropic?

    Sí. Cambia createAnthropic por createOpenAI o createGoogleGenerativeAI en AiService y actualiza el nombre del modelo. El resto del módulo — controlador, DTO, rate limiting, manejo de errores — no cambia. Esa es exactamente la ventaja de usar el AI SDK como capa de abstracción: cambias de proveedor en un sitio.

    ¿CoreMessage[] es compatible con el formato de mensajes que manda el componente Angular del post anterior?

    CoreMessage del AI SDK acepta objetos con role ('user', 'assistant', 'system') y content (string). El ChatMessage del componente Angular del post anterior tiene exactamente esa forma. El cast body.messages as CoreMessage[] funciona directamente — no necesitas transformar nada.


    Cierre

    Un backend de streaming de IA no es complicado. Lo que sí es complicado es hacerlo bien desde el principio: que valide los inputs, que no queme tokens cuando el cliente desconecta, que no se caiga cuando Anthropic devuelve un 429, que tenga un límite razonable de peticiones por IP.

    NestJS más el Vercel AI SDK resuelven ese conjunto de problemas con una arquitectura que ya conoces si llevas tiempo en el ecosistema TypeScript. No hay magia — hay módulos, servicios, inyección de dependencias, y un stream que fluye limpio de principio a fin.

    El AiModule que has construido hoy es reutilizable. Impórtalo en cualquier NestJS existente, ajusta el system prompt y el modelo, y tienes un endpoint de IA en producción en menos de una hora.

    Si quieres llevarlo más lejos — tool calls, agentes con memoria, pipelines de documentos — en Dominicode Labs tenemos los proyectos completos con los patrones que usamos en producción, incluyendo ejemplos de NestJS con AI SDK con autenticación, persistencia y cancelación de streams.


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

  • Prompt Caching en Claude: reduce tu factura de API un 90%

    Prompt Caching en Claude: reduce tu factura de API un 90%

    El mes pasado revisé los gastos de API de un proyecto que lleva seis semanas en producción. Un agente conversacional para análisis de documentos legales. El cliente lo usa unas 40 veces al día.

    La factura: $340 en un mes.

    El system prompt tenía 8.000 tokens. Las definiciones de herramientas, otros 3.000. En cada llamada, esos 11.000 tokens se procesaban desde cero. Cuarenta veces al día. Treinta días al mes.

    Activé prompt caching. La siguiente factura: $38.

    No cambié la lógica del agente. No modifiqué los prompts. Solo añadí tres líneas de configuración.

    Eso es lo que hace el prompt caching de Claude. Y la mayoría de developers que trabajan con la API de Anthropic aún no lo tienen activado.


    Qué es el prompt caching y cómo funciona

    Cuando haces una llamada a la API de Claude, pagas por cada token que el modelo procesa. System prompt, herramientas, historial de conversación, contexto de documentos: todo se cobra como tokens de entrada.

    El problema es que en la mayoría de aplicaciones reales, una parte enorme de esos tokens es idéntica en cada llamada. Tu system prompt no cambia. Las definiciones de tus herramientas no cambian. El contexto de un documento que estás analizando no cambia entre preguntas del usuario.

    El prompt caching te permite marcar esas partes estáticas para que Claude las almacene en caché. La documentación oficial de prompt caching cubre todos los modelos y casos edge. La primera vez que se procesa ese contenido, se escribe en caché. En las llamadas posteriores, en lugar de reprocesar esos tokens, Claude los lee desde el caché.

    El coste de un cache write es 1.25x el precio base — ligeramente más caro que una llamada normal. El coste de un cache read es 0.1x el precio base. Es decir, un 90% más barato.

    En un agent loop con 40 llamadas al día, pagas el 1.25x una vez. Las otras 39 veces pagas el 0.1x. La aritmética es brutal a tu favor.

    El TTL del caché

    El caché tiene un TTL (Time To Live) de 5 minutos por defecto. Mientras haya llamadas dentro de esa ventana, el caché se renueva automáticamente sin coste adicional. Si una conversación tiene mensajes frecuentes, el caché se mantiene activo.

    Existe también un TTL de 1 hora, que cuesta 2x el precio base en la escritura. Útil cuando tienes contextos que se reutilizan con menos frecuencia pero son muy costosos de regenerar.

    El mínimo de tokens para activar el caché

    No todo se puede cachear. El sistema exige un mínimo de tokens para crear una entrada de caché. Para claude-sonnet-4-6 y claude-opus-4-8, el mínimo es 1.024 tokens. Para claude-haiku-4-5, el umbral sube a 4.096 tokens — cuatro veces más alto, relevante si usas Haiku con prompts cortos. Si tu system prompt tiene menos tokens que el mínimo de tu modelo, el caché no se activa.

    En proyectos donde el system prompt es corto, la estrategia correcta es incluir el contexto del dominio directamente en el system prompt hasta superar ese umbral, o cachear las definiciones de herramientas junto con el sistema.


    Cómo habilitarlo: código TypeScript con el SDK oficial

    Aquí está el patrón que uso en producción. Nada de magia — tres cambios concretos en tu código.

    Habilitación básica: system prompt con cache_control

    import Anthropic from "@anthropic-ai/sdk";
    
    const client = new Anthropic();
    
    const response = await client.messages.create({
      model: "claude-sonnet-4-6",
      max_tokens: 1024,
      system: [
        {
          type: "text",
          text: `Eres un asistente especializado en análisis de documentos legales.
          
    Tu rol es:
    - Identificar cláusulas de riesgo en contratos
    - Resumir términos clave de forma clara y precisa
    - Señalar inconsistencias o ambigüedades legales
    - Comparar términos con estándares del sector
    
    [...aquí va el resto del system prompt extenso, con contexto del dominio,
    instrucciones detalladas, ejemplos de formato de respuesta, etc.
    Debe superar los 1.024 tokens para activar el caché...]`,
          cache_control: { type: "ephemeral" }, // <-- esto es todo lo que necesitas
        },
      ],
      messages: [
        {
          role: "user",
          content: "Analiza la cláusula de terminación de este contrato: ...",
        },
      ],
    });
    
    console.log(response.usage);
    

    En la primera llamada, usage mostrará:

    {
      "input_tokens": 45,
      "cache_creation_input_tokens": 1280,
      "cache_read_input_tokens": 0,
      "output_tokens": 312
    }
    

    En la segunda llamada (dentro de los 5 minutos):

    {
      "input_tokens": 45,
      "cache_creation_input_tokens": 0,
      "cache_read_input_tokens": 1280,
      "output_tokens": 289
    }
    

    cache_read_input_tokens tiene el 10% del coste. El system prompt completo se leyó desde caché. Esos 1.280 tokens no se procesaron desde cero.

    Cacheando herramientas y system prompt juntos

    Cuando tienes definiciones de herramientas largas — algo habitual en agentes con MCP o con múltiples funciones — el ahorro se multiplica. Aquí el patrón para cachear ambas cosas:

    import Anthropic from "@anthropic-ai/sdk";
    
    const client = new Anthropic();
    
    // Las definiciones de herramientas son estáticas — candidatas perfectas para caché
    const tools: Anthropic.Tool[] = [
      {
        name: "search_legal_database",
        description: `Busca en la base de datos legal precedentes y jurisprudencia relevante.
        Usa esta herramienta cuando necesites comparar cláusulas con casos anteriores o
        encontrar interpretaciones judiciales de términos específicos. La búsqueda incluye
        bases de datos de España, México, Argentina y Colombia. Devuelve hasta 10 resultados
        ordenados por relevancia con fecha, tribunal y resumen del caso.`,
        input_schema: {
          type: "object" as const,
          properties: {
            query: {
              type: "string",
              description: "Término o frase legal a buscar",
            },
            jurisdiction: {
              type: "string",
              enum: ["ES", "MX", "AR", "CO", "ALL"],
              description: "Jurisdicción a consultar",
            },
            date_range: {
              type: "string",
              description: "Rango de fechas en formato YYYY-YYYY",
            },
          },
          required: ["query"],
        },
      },
      {
        name: "analyze_clause_risk",
        description: `Analiza el nivel de riesgo de una cláusula contractual.
        Evalúa factores como onerosidad excesiva, cláusulas abusivas según legislación
        vigente, asimetría de obligaciones y exposición a penalidades. Devuelve un score
        de riesgo del 1 al 10 con justificación detallada y recomendaciones de negociación.`,
        input_schema: {
          type: "object" as const,
          properties: {
            clause_text: {
              type: "string",
              description: "Texto completo de la cláusula a analizar",
            },
            contract_type: {
              type: "string",
              description: "Tipo de contrato (laboral, mercantil, arrendamiento, etc.)",
            },
          },
          required: ["clause_text"],
        },
      },
      // cache_control al final del array de tools — marca el punto de caché
    ];
    
    // Añadimos cache_control al último tool para cachear todo el bloque
    const toolsWithCache = tools.map((tool, index) =>
      index === tools.length - 1
        ? { ...tool, cache_control: { type: "ephemeral" as const } }
        : tool
    );
    
    const response = await client.messages.create({
      model: "claude-sonnet-4-6",
      max_tokens: 2048,
      system: [
        {
          type: "text",
          text: "Eres un asistente especializado en análisis legal...",
          cache_control: { type: "ephemeral" }, // system prompt cacheado
        },
      ],
      tools: toolsWithCache, // tools cacheadas
      messages: [
        {
          role: "user",
          content: "¿Cuál es el riesgo de esta cláusula de no competencia?",
        },
      ],
    });
    

    Monitorizar el ahorro en tiempo real

    Esta función te dice exactamente cuánto has ahorrado en cada llamada:

    interface CostMonitor {
      inputTokensCost: number;
      cacheWriteCost: number;
      cacheReadCost: number;
      outputTokensCost: number;
      totalCost: number;
      savings: number;
      savingsPercent: number;
    }
    
    // Precios para claude-sonnet-4-6 por millón de tokens (en dólares)
    const PRICING = {
      input: 3.0,
      cacheWrite: 3.75, // 1.25x
      cacheRead: 0.3,   // 0.1x
      output: 15.0,
    };
    
    function calculateCallCost(usage: Anthropic.Usage): CostMonitor {
      const inputCost = (usage.input_tokens / 1_000_000) * PRICING.input;
      const cacheWriteCost =
        ((usage.cache_creation_input_tokens ?? 0) / 1_000_000) * PRICING.cacheWrite;
      const cacheReadCost =
        ((usage.cache_read_input_tokens ?? 0) / 1_000_000) * PRICING.cacheRead;
      const outputCost = (usage.output_tokens / 1_000_000) * PRICING.output;
    
      const totalCost = inputCost + cacheWriteCost + cacheReadCost + outputCost;
    
      // Coste hipotético sin caché (todos los tokens al precio base)
      const totalInputTokens =
        usage.input_tokens +
        (usage.cache_creation_input_tokens ?? 0) +
        (usage.cache_read_input_tokens ?? 0);
      const costWithoutCache =
        (totalInputTokens / 1_000_000) * PRICING.input + outputCost;
    
      const savings = costWithoutCache - totalCost;
      const savingsPercent =
        costWithoutCache > 0 ? (savings / costWithoutCache) * 100 : 0;
    
      return {
        inputTokensCost: inputCost,
        cacheWriteCost,
        cacheReadCost,
        outputTokensCost: outputCost,
        totalCost,
        savings,
        savingsPercent,
      };
    }
    
    // Uso:
    const monitor = calculateCallCost(response.usage);
    console.log(`Ahorro: $${monitor.savings.toFixed(6)} (${monitor.savingsPercent.toFixed(1)}%)`);
    

    Qué debes cachear y qué no

    Los mejores candidatos para el caché

    System prompts largos. Es el caso más obvio. Si tu system prompt tiene instrucciones de rol, reglas de formato, contexto del dominio y ejemplos, estás mirando fácilmente 2.000-8.000 tokens que se repiten en cada llamada. Cachear el system prompt es lo primero que debes activar.

    Definiciones de herramientas (tools). Especialmente en agentes con MCP o con muchas funciones. Las definiciones de tools incluyen nombres, descripciones detalladas y schemas completos. Pueden sumar 3.000-5.000 tokens fácilmente. Son siempre estáticas dentro de una sesión.

    Contexto de documentos. Si tu aplicación analiza un documento largo (un contrato, una especificación técnica, un PDF), ese documento va en el mensaje del usuario pero cambia muy poco. Puedes cachearlo con cache_control en el bloque del contenido del mensaje.

    Historial de conversación en agent loops. En un loop donde el agente tiene muchos turnos, cachear el historial acumulado evita pagar por reprocesar el contexto completo en cada iteración.

    Qué NO debes cachear

    El turno actual del usuario. Es el error más común. El mensaje que el usuario acaba de escribir cambia en cada llamada — si intentas cachearlo, el caché nunca tendrá un hit porque el contenido es siempre distinto.

    Tokens de extended thinking. Si usas extended thinking con Claude, los tokens del proceso de razonamiento interno no se cachean. Esto es relevante si estás midiendo ahorros en pipelines que usan thinking — los números no escalarán de la misma forma.

    Contenido que cambia con frecuencia. Si tienes un bloque de contexto que se actualiza cada pocos minutos (resultados de una búsqueda en tiempo real, estado de una sesión volátil), no tiene sentido marcarlo para caché porque nunca habrá un hit.

    Bloques demasiado pequeños. Si un bloque tiene menos de 1.024 tokens, el sistema no lo cacheará. No añadas cache_control a fragmentos pequeños — solo añade latencia sin beneficio.


    Comparación de coste: sin caching vs con caching

    Escenario real: un agente con 40 llamadas diarias durante 30 días.

    • System prompt: 5.000 tokens
    • Tools: 3.000 tokens
    • Pregunta del usuario: ~100 tokens (variable)
    • Respuesta del modelo: ~400 tokens (variable)
    • Modelo: claude-sonnet-4-6
    Escenario Coste por llamada Total mensual
    Sin caching (8.100 input + 400 output) $0.0303 $36.36
    Con caching — 1ª llamada del día (cache write 8.000 + 100 input + 400 output) $0.037
    Con caching — llamadas 2–40 (cache read 8.000 + 100 input + 400 output) $0.0084
    Con caching — total diario (1ª + 39 × $0.0084) $0.365/día $10.95

    Ahorro: 70%. Y esto asumiendo que el caché expira cada día. Con conversaciones más densas donde el TTL de 5 minutos se aprovecha bien, el ahorro sube al 85-90%.


    Preguntas frecuentes sobre prompt caching en Claude

    ¿El caché es compartido entre usuarios?
    No. El caché es privado por workspace de Anthropic. Desde febrero de 2026, hay aislamiento completo por workspace. Los datos de un usuario nunca se mezclan con los de otro.

    ¿Qué pasa si cambio el system prompt? ¿Se invalida el caché?
    Sí. El caché funciona por contenido exacto. Si modificas un solo carácter del bloque cacheado, se genera una nueva entrada de caché (cache write) en la siguiente llamada. El caché anterior expira según su TTL sin coste adicional.

    ¿Puedo cachear múltiples bloques en la misma llamada?
    Sí, hasta un máximo de cuatro breakpoints de caché por request. La restricción importante es el orden: los bloques con TTL más largo (1 hora) deben aparecer antes que los de TTL más corto (5 minutos) en la estructura del request.

    ¿El caching funciona con streaming?
    Sí. El prompt caching es compatible con la API de streaming de Claude. Los campos cache_creation_input_tokens y cache_read_input_tokens aparecen en el evento message_start del stream — no en message_delta. Es el primer evento emitido, antes de que lleguen los tokens de respuesta.


    El siguiente nivel: combinar con Claude Code

    Si ya estás explorando agentes más complejos, el prompt caching cambia la ecuación de coste de forma radical. Un agent loop sin caching que hace 10 iteraciones paga los tokens del system prompt y las tools diez veces. Con caching, los paga una vez y lee el resto.

    En Claude Code: Effort, Models, Tools y Context hay una sección completa sobre cómo gestiona Claude Code el contexto en agent loops largos — es el contexto perfecto para entender dónde encaja el caching a nivel de infraestructura.

    Y si quieres construir productos reales sobre la API de Anthropic con esta clase de optimizaciones ya integradas desde el primer sprint, el curso Construye con IA: De la Idea al Producto con Claude cubre el stack completo — desde la arquitectura del agente hasta el control de costes en producción.


    Lo que puedes hacer hoy

    Si tienes una aplicación que usa la API de Claude en producción, abre el código y busca dónde defines el system prompt. Si es una cadena de texto plana, conviértela en un array con cache_control: { type: "ephemeral" }.

    Eso solo. Una línea de cambio. Comprueba la siguiente factura.

    Si además tienes tools largas, aplica el mismo patrón al último elemento del array de herramientas. Tendrás dos puntos de caché activos y el ahorro será inmediato.

    El prompt caching no es una optimización avanzada que requiere rediseñar tu arquitectura. Es una configuración de tres minutos que debería estar activa en cualquier aplicación seria sobre la API de Claude. Si no la tienes, estás pagando de más desde el primer día.


    Bezael Pérez — Fundador de Dominicode. Developer senior con 15+ años construyendo software. Si construyes con IA y quieres profundizar más allá de los tutoriales, en Dominicode Labs estamos trabajando en proyectos reales con la API de Anthropic, arquitecturas de agentes y todo lo que no cabe en un post.

  • Angular v22 + Vercel AI SDK: streaming de IA con Signals

    Angular v22 + Vercel AI SDK: streaming de IA con Signals

    ANTHROPIC_API_KEY=sk-ant-xxxxxxxx

    
    ---
    
    ## Paso 1: El servidor backend con streamText
    
    Crea un archivo `server/chat.ts` fuera del proyecto Angular (o en un monorepo aparte). Este servidor tiene un solo endpoint: recibe mensajes, llama a Claude, y hace streaming de la respuesta.
    
    ```typescript
    // server/chat.ts
    import { streamText } from 'ai';
    import { anthropic } from '@ai-sdk/anthropic';
    
    const server = Bun.serve({
      port: 3000,
      async fetch(req) {
        // CORS para desarrollo local
        if (req.method === 'OPTIONS') {
          return new Response(null, {
            headers: {
              'Access-Control-Allow-Origin': '*',
              'Access-Control-Allow-Methods': 'POST, OPTIONS',
              'Access-Control-Allow-Headers': 'Content-Type',
            },
          });
        }
    
        if (req.method === 'POST' && new URL(req.url).pathname === '/api/chat') {
          const { messages } = await req.json();
    
          const result = streamText({
            model: anthropic('claude-sonnet-4-6'),
            system: 'Eres un asistente técnico especializado en Angular y desarrollo frontend moderno. Responde en español de forma concisa y directa.',
            messages,
          });
    
          return result.toTextStreamResponse({
            headers: {
              'Access-Control-Allow-Origin': '*',
            },
          });
        }
    
        return new Response('Not found', { status: 404 });
      },
    });
    
    console.log(`Servidor corriendo en http://localhost:${server.port}`);
    

    Arrancar el servidor:

    bun run server/chat.ts
    

    streamText de la AI SDK devuelve un objeto con varios métodos. toTextStreamResponse() genera una Response HTTP estándar con Content-Type: text/plain; charset=utf-8 y Transfer-Encoding: chunked — exactamente lo que necesita el cliente para consumir el stream token a token.


    Paso 2: El modelo de datos

    Antes del componente, define la interfaz de mensaje. Simple:

    // src/app/chat/chat.types.ts
    export interface ChatMessage {
      role: 'user' | 'assistant';
      content: string;
    }
    

    Paso 3: El componente Angular v22 con Signals

    Aquí es donde la magia ocurre. No necesitas HttpClient con responseType: 'text' — eso no soporta streaming incremental. Necesitas fetch nativo con ReadableStream.

    // src/app/chat/chat.component.ts
    import {
      Component,
      signal,
      computed,
      ChangeDetectionStrategy,
    } from '@angular/core';
    import { FormsModule } from '@angular/forms';
    import { ChatMessage } from './chat.types';
    
    @Component({
      selector: 'app-chat',
      standalone: true,
      imports: [FormsModule],
      changeDetection: ChangeDetectionStrategy.OnPush,
      templateUrl: './chat.component.html',
    })
    export class ChatComponent {
      messages = signal<ChatMessage[]>([]);
      userInput = signal('');
      isStreaming = signal(false);
    
      canSend = computed(
        () => this.userInput().trim().length > 0 && !this.isStreaming()
      );
    
      async sendMessage() {
        const content = this.userInput().trim();
        if (!content || this.isStreaming()) return;
    
        // Añadir mensaje del usuario
        this.messages.update((msgs) => [
          ...msgs,
          { role: 'user', content },
        ]);
        this.userInput.set('');
        this.isStreaming.set(true);
    
        // Capturar mensajes ANTES del placeholder — la API rechaza content vacío como último mensaje
        const messagesToSend = this.messages();
    
        // Placeholder para la respuesta del asistente
        this.messages.update((msgs) => [
          ...msgs,
          { role: 'assistant', content: '' },
        ]);
    
        try {
          const response = await fetch('http://localhost:3000/api/chat', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ messages: messagesToSend }),
          });
    
          if (!response.ok) throw new Error(`HTTP ${response.status}`);
          if (!response.body) throw new Error('No stream body');
    
          const reader = response.body.getReader();
          const decoder = new TextDecoder();
    
          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
    
            const chunk = decoder.decode(value, { stream: true });
    
            // Actualiza el último mensaje (el del asistente) acumulando el chunk
            this.messages.update((msgs) => {
              const updated = [...msgs];
              const last = updated[updated.length - 1];
              updated[updated.length - 1] = {
                ...last,
                content: last.content + chunk,
              };
              return updated;
            });
          }
        } catch (error) {
          console.error('Error en streaming:', error);
          this.messages.update((msgs) => {
            const updated = [...msgs];
            updated[updated.length - 1] = {
              role: 'assistant',
              content: 'Error al conectar con el servidor. Comprueba que el backend está corriendo.',
            };
            return updated;
          });
        } finally {
          this.isStreaming.set(false);
        }
      }
    
      handleEnter(event: KeyboardEvent) {
        if (event.key === 'Enter' && !event.shiftKey) {
          event.preventDefault();
          this.sendMessage();
        }
      }
    }
    

    Tres decisiones clave en este componente:

    messages = signal<ChatMessage[]>([]) — todo el historial de la conversación vive en un signal. Cada vez que llega un chunk, actualizamos el último mensaje del array con update(). Angular detecta el cambio y re-renderiza solo ese elemento.

    ChangeDetectionStrategy.OnPush — esencial para este patrón. Sin esto, Angular ejecutaría la detección de cambios en cada tick mientras el stream está activo. Con OnPush + Signals, Angular solo actualiza cuando el signal cambia — que es exactamente cuando llega un chunk nuevo.

    fetch nativo en lugar de HttpClientHttpClient es poderoso para peticiones normales, pero para streaming necesitas acceso al ReadableStream crudo del Response. fetch te da eso directamente con response.body.getReader().


    Paso 4: El template con el nuevo control flow

    El template aprovecha el control flow de Angular v17+ (@for, @if) y lee los signals directamente — sin async pipe, sin | async, sin subscripciones.

    <!-- src/app/chat/chat.component.html -->
    <div class="chat-container">
      <div class="messages-area">
        @if (messages().length === 0) {
          <p class="empty-state">Escribe un mensaje para empezar.</p>
        }
    
        @for (msg of messages(); track $index) {
          <div class="message" [class]="msg.role">
            <span class="role-label">
              {{ msg.role === 'user' ? 'Tú' : 'Asistente' }}
            </span>
            <p class="message-content">{{ msg.content }}</p>
    
            @if (msg.role === 'assistant' && $last && isStreaming()) {
              <span class="cursor-blink">|</span>
            }
          </div>
        }
      </div>
    
      <div class="input-area">
        <textarea
          [value]="userInput()"
          (input)="userInput.set($any($event.target).value)"
          (keydown)="handleEnter($event)"
          placeholder="Escribe tu mensaje... (Enter para enviar)"
          rows="3"
          [disabled]="isStreaming()"
        ></textarea>
    
        <button
          (click)="sendMessage()"
          [disabled]="!canSend()"
        >
          @if (isStreaming()) {
            Generando...
          } @else {
            Enviar
          }
        </button>
      </div>
    </div>
    

    El cursor parpadeante | aparece solo en el último mensaje del asistente mientras isStreaming() es true. Es un detalle pequeño que hace que la experiencia se sienta viva.


    Paso 5: Estilos mínimos (opcional)

    /* src/app/chat/chat.component.css */
    .chat-container {
      display: flex;
      flex-direction: column;
      height: 100vh;
      max-width: 800px;
      margin: 0 auto;
      padding: 1rem;
      gap: 1rem;
    }
    
    .messages-area {
      flex: 1;
      overflow-y: auto;
      display: flex;
      flex-direction: column;
      gap: 1rem;
      padding: 1rem;
      border: 1px solid #e5e7eb;
      border-radius: 0.5rem;
    }
    
    .message {
      padding: 0.75rem 1rem;
      border-radius: 0.5rem;
      max-width: 80%;
    }
    
    .message.user {
      background: #e90464;
      color: white;
      align-self: flex-end;
    }
    
    .message.assistant {
      background: #f3f4f6;
      color: #111827;
      align-self: flex-start;
    }
    
    .role-label {
      font-size: 0.75rem;
      font-weight: 600;
      opacity: 0.7;
      display: block;
      margin-bottom: 0.25rem;
    }
    
    .cursor-blink {
      animation: blink 1s step-end infinite;
    }
    
    @keyframes blink {
      50% { opacity: 0; }
    }
    
    .input-area {
      display: flex;
      gap: 0.5rem;
    }
    
    textarea {
      flex: 1;
      padding: 0.75rem;
      border: 1px solid #d1d5db;
      border-radius: 0.5rem;
      resize: none;
      font-family: inherit;
    }
    
    button {
      padding: 0.75rem 1.5rem;
      background: #e90464;
      color: white;
      border: none;
      border-radius: 0.5rem;
      cursor: pointer;
      font-weight: 600;
      align-self: flex-end;
    }
    
    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    

    El resultado

    Arranca los dos procesos:

    # Terminal 1: backend
    bun run server/chat.ts
    
    # Terminal 2: Angular
    ng serve
    

    Abre http://localhost:4200. Escribe cualquier pregunta técnica. Las palabras aparecen token a token mientras Claude las genera. El botón muestra "Generando…" y el cursor parpadea al final del último mensaje.

    Eso es streaming real, en Angular v22, con Signals, en menos de 20 minutos.


    Por qué este patrón funciona bien en producción

    Si quieres entender cómo se conectan estos patrones con el desarrollo de productos completos con IA, el post sobre cómo crear productos con IA para vender muestra el panorama completo.

    Lo que tienes aquí no es un prototipo. Es un patrón que escala:

    El estado es predecible. Todo vive en messages = signal<ChatMessage[]>([]). No hay subscripciones dispersas, no hay Subject de BehaviorSubject, no hay que recordar hacer unsubscribe. El signal se actualiza, Angular re-renderiza lo necesario, punto.

    El backend es stateless. Cada petición envía el historial completo de mensajes. Así funciona la API de Anthropic — no hay sesión en el servidor, lo que facilita el escalado horizontal.

    ChangeDetectionStrategy.OnPush es obligatorio aquí. Con Zone.js y la detección de cambios por defecto, Angular correría su ciclo de detección constantemente mientras el stream está activo. Con OnPush + Signals, solo actualiza cuando el signal cambia.

    Si quieres llevar esto más allá — añadir herramientas (tool calls), mantener sesiones con localStorage, o integrar el chat dentro de una app Angular más grande con routing y autenticación — el patrón es el mismo. Cambias el modelo en el servidor, añades tools a streamText, y el componente no necesita modificarse.

    Si ya tienes experiencia con Angular y quieres dominar Signals, componentes standalone y el control flow moderno que hemos usado aquí, en el Curso Angular Moderno lo cubrimos desde la arquitectura hasta producción — incluyendo patrones de integración con APIs externas como esta.

    Y si quieres ir más allá del chat básico y construir agentes reales con Claude — con herramientas, contexto persistente, y pipelines de desarrollo AI-first — eso es exactamente lo que enseñamos en Construye con IA: de la idea al producto con Claude Code.


    FAQ

    ¿Necesito Angular Universal (SSR) para que esto funcione?

    No. El streaming ocurre entre el cliente Angular (browser) y el servidor Bun que creamos. Angular SSR es irrelevante para este patrón — el componente de chat vive completamente en el cliente. Si tienes SSR activado, asegúrate de que el componente de chat solo se renderiza en el browser con isPlatformBrowser o usando @defer.

    ¿Puedo usar el mismo enfoque con OpenAI o Google Gemini en lugar de Anthropic?

    Sí. Cambia @ai-sdk/anthropic por @ai-sdk/openai o @ai-sdk/google, y sustituye anthropic('claude-sonnet-4-6') por openai('gpt-4o') o google('gemini-2.5-pro'). El resto del código — el componente Angular, el consumo del stream, los Signals — no cambia. Esa es una de las ventajas del Vercel AI SDK: abstrae el proveedor.

    ¿Qué pasa si el usuario envía el siguiente mensaje mientras el anterior aún está en streaming?

    El botón está deshabilitado mientras isStreaming() es true gracias al computed canSend. El usuario no puede enviar otro mensaje hasta que el stream termine. Si quieres cancelar el stream activo al recibir un nuevo mensaje, puedes guardar el reader como propiedad del componente y llamar a reader.cancel() antes de iniciar la nueva petición.

    ¿Cómo manejo el historial para conversaciones largas?

    La API de Anthropic tiene un límite de tokens por request. Para conversaciones largas, lo más simple es limitar el historial que envías al servidor — por ejemplo, los últimos 20 mensajes. En producción, lo correcto es implementar una ventana deslizante o resumir el historial antiguo con un llamada previa al modelo. Por ahora, con this.messages().slice(-20) en el body del fetch tienes un control básico suficiente para empezar.

    ¿Puedo usar HttpClient en lugar de fetch nativo?

    HttpClient con responseType: 'text' recibe el texto completo cuando la conexión cierra — no es streaming incremental. Para streaming real necesitas acceso al ReadableStream crudo de la Response, que solo fetch te proporciona directamente. Podrías implementar un interceptor custom o un HttpBackend alternativo, pero la complejidad no vale la pena. fetch nativo es la solución correcta aquí.


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

  • Neon vs Supabase: comparación técnica honesta (2026)

    Neon vs Supabase: comparación técnica honesta (2026)

    Hace unos meses estaba arrancando un proyecto nuevo. Stack limpio, decisiones por tomar. Y en cuestión de minutos tuve el debate de siempre en el canal de decisiones técnicas: ¿Neon o Supabase?

    Los dos son Postgres. Los dos tienen free tier. Los dos aparecen en casi cualquier lista de "stack moderno para SaaS". Y los dos hacen cosas completamente distintas.

    Este post es la Neon vs Supabase comparación técnica que me hubiera ahorrado dos horas de lectura de docs.


    Qué es cada uno en una línea

    Neon: Postgres serverless puro con branching de base de datos y scale-to-zero.

    Supabase: Plataforma BaaS construida sobre Postgres — incluye Auth, Storage, Realtime y Edge Functions en un solo sitio.

    La diferencia de fondo: Neon es una base de datos. Supabase es un backend completo que usa Postgres como motor.


    Tabla comparativa

    Criterio Neon Supabase
    Tipo Postgres serverless BaaS completo sobre Postgres
    Scale-to-zero Sí, nativo No en producción (pausa en free tier)
    Branching de DB Sí, copy-on-write instantáneo Solo Pro+ (beta); provisiona DB nueva + migraciones
    Auth nativo No Sí (JWT, OAuth, Magic Link)
    Storage nativo No Sí (S3-compatible)
    Realtime No Sí (WebSockets sobre Postgres)
    Edge Functions No Sí (Deno runtime)
    Free tier DB 0.5 GB, 100 CU-h 500 MB, pausa tras 7 días idle
    Plan de pago Desde ~$19/mes (usage-based) $25/mes (Pro, todo incluido)
    ORM compatible Cualquiera (Drizzle, Prisma, pg) Cliente JS/TS propio + cualquier ORM
    Tipado auto-generado Via ORM Sí, con supabase gen types
    Adquirida por Databricks (2025, ~$1B) Independiente

    Arquitectura de Neon vs Supabase: la diferencia que más importa

    Neon separa compute y storage. Cuando no hay peticiones, el compute se apaga solo — y cuando llega la primera query, arranca en milisegundos. El storage usa copy-on-write, lo que hace que crear una rama de base de datos sea instantáneo y casi sin coste.

    Supabase no funciona así. Tu base de datos corre en una instancia dedicada. Si estás en el free tier, Supabase pausa el proyecto tras 7 días sin actividad. En Pro, la instancia corre siempre — pagas compute 24/7 aunque tu app esté durmiendo.

    Para proyectos en producción con tráfico real, esto no es necesariamente un problema. Para proyectos con muchos entornos (staging, feature branches, demos de clientes), la diferencia de coste es brutal.


    Branching: por qué Neon gana en CI/CD

    Esta es la feature que más me ha cambiado el flujo de trabajo.

    Con Neon puedes crear una rama de base de datos por PR. Misma estructura, mismos datos (o un subconjunto). La rama vive mientras dura el PR y desaparece al hacer merge. No hay que mantener un entorno de staging contaminado con datos de otras features. Si tienes un pipeline con code review automático antes del merge, tienes el flujo completo en el post sobre agentic code review con Claude Code.

    # Crear una rama de DB para una PR concreta
    neon branches create --name feature/payment-refactor --parent main
    

    Supabase también tiene branching, pero funciona diferente: aprovisiona una base de datos nueva, ejecuta tus migraciones y carga el seed. Es más lento y consume más recursos. Para un equipo pequeño o un proyecto personal, puede ser suficiente. Para un pipeline de CI/CD que crea y destruye entornos constantemente, Neon gana por goleada.


    SDK y DX: dos filosofías distintas

    Supabase tiene un cliente JS/TS que abstrae casi todo. Queries, auth, storage, realtime — todo desde el mismo objeto.

    // Supabase: cliente unificado con tipado auto-generado
    import { createClient } from '@supabase/supabase-js'
    import type { Database } from './database.types' // generado con supabase gen types
    
    const supabase = createClient<Database>(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_ANON_KEY!
    )
    
    const { data, error } = await supabase
      .from('products')
      .select('id, name, price')
      .eq('active', true)
    

    Neon apuesta por Postgres nativo. Usas tu ORM de siempre — Drizzle, Prisma, o pg directo — contra un connection string estándar. Sin abstracciones propias, sin vendor lock-in de cliente.

    // Neon: Drizzle sobre el driver serverless de Neon
    import { neon } from '@neondatabase/serverless'
    import { drizzle } from 'drizzle-orm/neon-http'
    import { products } from './schema'
    import { eq } from 'drizzle-orm'
    
    const sql = neon(process.env.DATABASE_URL!)
    const db = drizzle(sql)
    
    const activeProducts = await db
      .select({ id: products.id, name: products.name, price: products.price })
      .from(products)
      .where(eq(products.active, true))
    

    Si ya tienes un ORM configurado en tu proyecto, migrar a Neon es cambiar el connection string. Con Supabase, el cliente propio es más cómodo para proyectos nuevos pero añade una dependencia específica a la plataforma.


    Precios Neon vs Supabase: cuándo cada modelo tiene sentido

    Neon (usage-based):

    • Free: $0 — 100 CU-horas, 0.5 GB storage
    • Launch: ~$19/mes — $0.106/CU-hora, $0.35/GB storage
    • Scale: desde ~$701/mes — con SLA e HIPAA incluidos

    Supabase (plataforma flat + overages):

    • Free: $0 — 500 MB DB, 50K MAU, 1 GB storage (pausa tras 7 días idle)
    • Pro: $25/mes — 8 GB DB, 100K MAU, Auth + Storage + Edge Functions incluidos
    • Team: $599/mes — SSO, SOC 2

    Para un proyecto con tráfico irregular o muchos entornos temporales, el modelo de Neon puede salir significativamente más barato. Para un SaaS en crecimiento que necesita Auth + Storage + DB y quiere una sola factura, el Pro de Supabase a $25 es imbatible en relación precio/funcionalidad.

    Precios verificados en junio 2026. Consulta las páginas oficiales de Neon y Supabase para tarifas actualizadas — los modelos usage-based cambian con frecuencia.


    Cuándo elegir Neon

    • Quieres Postgres puro sin opiniones sobre tu stack de auth o storage.
    • Tu pipeline de CI/CD se beneficia de tener una rama de DB por PR.
    • Tienes cargas de trabajo variables o intermitentes — el scale-to-zero te ahorra dinero real.
    • Ya tienes Drizzle o Prisma configurado y no quieres añadir un cliente propio.
    • Estás construyendo agentes de IA que necesitan provisionar bases de datos efímeras. La arquitectura serverless de Neon (y el respaldo de Databricks) la convierte en la opción natural para cargas de trabajo agénticas — incluidos los pipelines donde el agente lee un ticket, implementa y despliega de forma autónoma, como los que explico en el post sobre automatizar el proceso de desarrollo con IA.

    Cuándo elegir Supabase

    • Necesitas Auth desde el día uno — OAuth, magic link, JWT — sin montar Clerk ni Auth.js.
    • Tu proyecto necesita file storage y no quieres gestionar un bucket S3 por tu cuenta.
    • Quieres Realtime (subscripciones en tiempo real) sin añadir Redis ni WebSockets propios.
    • Valoras tener un solo proveedor para DB + Auth + Storage con una sola factura.
    • El free tier te basta para empezar y no te molesta que el proyecto se pause tras 7 días idle.

    Edge cases que nadie menciona

    Supabase no hace scale-to-zero en producción. Esto es intencionado — una instancia siempre activa garantiza latencia consistente. Pero si tienes 10 entornos de staging o un proyecto que duerme la mayor parte del tiempo, estás pagando compute en vacío.

    Neon no tiene Auth ni Storage nativos. Si los necesitas, tienes que añadirlos tú: Clerk, Better Auth, Auth.js para autenticación; Cloudflare R2, AWS S3 o Uploadthing para ficheros. No es un problema técnico, pero sí es trabajo de integración que con Supabase viene resuelto de fábrica.

    El branching de Supabase requiere CLI y configuración previa. No es tan plug-and-play como en Neon. Si quieres branching en Supabase, necesitas tener migraciones bien organizadas desde el principio.


    FAQ

    ¿Puedo usar Drizzle con Supabase?
    Sí. Supabase es Postgres estándar — puedes conectar cualquier ORM con el connection string del proyecto. El cliente propio de Supabase es opcional, no obligatorio.

    ¿Neon tiene Realtime o Auth?
    No de forma nativa. Puedes añadir LISTEN/NOTIFY de Postgres para eventos básicos, pero no hay un sistema de auth ni storage integrado. Para eso necesitas otra capa.

    ¿El scale-to-zero de Neon afecta a producción?
    Depende de tu configuración. El cold start de Neon suele estar por debajo de 500ms en condiciones normales. Para la mayoría de apps es aceptable. Si tienes requisitos de latencia muy estrictos, puedes configurar un mínimo de compute activo en el plan de pago.

    ¿Qué pasa con la adquisición de Neon por Databricks?
    Databricks compró Neon en 2025 por aproximadamente $1B. La apuesta es que los agentes de IA van a necesitar provisionar bases de datos efímeras a escala. Para el usuario, de momento se traduce en mejoras de precios — el storage bajó de $1.75 a $0.35/GB-mes. El roadmap a largo plazo aún está por verse.

    ¿Puedo migrar de Supabase a Neon (o al revés) más adelante?
    La base de datos en sí migra sin problema — es Postgres estándar en los dos casos. El trabajo real está en reemplazar el cliente de Supabase (auth, storage, realtime) si decides cambiar. Si usas Drizzle o Prisma desde el principio, cambiar el connection string es trivial.


    La decisión no es técnica en el fondo — es de qué quieres gestionar tú y qué quieres que gestione la plataforma. Si quieres Postgres puro con control total y branching en CI/CD, Neon. Si quieres un backend completo sin ensamblar piezas, Supabase.

    Ninguna de las dos es la respuesta correcta en abstracto. Ambas son la respuesta correcta para el problema adecuado.

    Si en tu proyecto estás usando IA para construir features o automatizar flujos, en el curso Construye con IA trabajamos exactamente con este tipo de decisiones de arquitectura — desde la elección de herramientas hasta el producto en producción.

    Si quieres profundizar en cómo tomar este tipo de decisiones de arquitectura en proyectos reales — con IA en el loop — en Dominicode Labs tenemos proyectos completos con el stack detallado y la justificación técnica detrás de cada decisión.


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

  • Algoritmos de machine learning que todo developer web debería entender

    Algoritmos de machine learning que todo developer web debería entender

    Hace un año integré una búsqueda semántica en un proyecto SaaS. El cliente quería que los usuarios encontraran artículos aunque escribieran con sinónimos, con errores ortográficos, o en un idioma distinto al del contenido.

    La solución: tres líneas de TypeScript llamando a algoritmos de machine learning vía la API de OpenAI. Funcionó en una tarde.

    Pero el cliente preguntó algo que me dejó sin respuesta inmediata: "¿Qué hace exactamente ese modelo por dentro?". Y yo, con 15 años de experiencia en desarrollo, tuve que admitir que no tenía una respuesta clara más allá de "convierte texto en números".

    Ese hueco me molestó. No porque necesitara implementar los algoritmos desde cero, sino porque cuando no entiendes qué hay debajo del capó, tomas peores decisiones: eliges el modelo equivocado, debuggeas en la dirección incorrecta, o diseñas una arquitectura que no escala.

    Este post es lo que me hubiera gustado leer ese día. Los algoritmos de machine learning explicados para developers web — sin fórmulas, sin Python, sin pretender que vas a ser data scientist.

    Tres familias que lo explican todo

    Los algoritmos de machine learning son procedimientos que permiten a un sistema aprender patrones a partir de datos, sin que un programador defina explícitamente las reglas. En lugar de escribir if (spam) { ... }, le muestras miles de emails al modelo y él deduce las reglas solo.

    Hay tres formas fundamentales en que ocurre ese aprendizaje:

    Aprendizaje supervisado. Le das al modelo ejemplos con respuesta correcta. "Este email es spam. Este otro no lo es." El modelo aprende el patrón. Cuando llega un email nuevo, predice a cuál categoría pertenece. Úsalo cuando tienes datos etiquetados y una tarea de predicción o clasificación concreta.

    Aprendizaje no supervisado. No hay respuestas correctas. Le das datos sin etiquetar y el modelo encuentra estructura por sí solo. "Estos usuarios tienen comportamiento parecido. Estos otros también. Hay tres grupos." Úsalo cuando quieres descubrir patrones que no conoces de antemano — clustering de usuarios, detección de anomalías.

    Reinforcement learning. El modelo aprende por ensayo y error: hace una acción, recibe una recompensa o penalización, ajusta. Es cómo funcionan los modelos de juegos, pero también cómo se afinan los LLMs para que sus respuestas sean más útiles (RLHF — Reinforcement Learning from Human Feedback).

    Con esto en mente, los algoritmos concretos tienen contexto.

    Los algoritmos de machine learning que te importan como developer

    Resumen antes de entrar en detalle — ninguno lo vas a implementar tú:

    Algoritmo Tipo Cuándo lo usas en web ¿Lo implementas?
    Regresión logística Supervisado Scoring, predicción de churn No — API
    Random Forest Supervisado Moderación, detección de fraude No — API
    K-Means No supervisado Clustering de usuarios No — API
    Redes neuronales Supervisado Base de embeddings, clasificación No — modelos preentrenados
    Embeddings Supervisado Búsqueda semántica, recomendaciones No — OpenAI/HuggingFace
    Transformers Supervisado LLMs, generación, clasificación avanzada No — API

    Regresión lineal y logística

    Son los más simples. La regresión lineal predice un número: "¿Cuánto va a costar este apartamento?" La logística predice una probabilidad: "¿Hay un 87% de probabilidad de que este usuario cancele su suscripción este mes?"

    No las vas a implementar, pero las vas a encontrar en APIs de scoring, en features de predicción de churn, en sistemas de precios dinámicos. Cuando una API te devuelve un score: 0.87, probablemente hay una regresión logística detrás.

    Árboles de decisión y Random Forest

    Imagina una serie de preguntas de sí/no encadenadas. "¿El usuario tiene más de 30 días de cuenta? ¿Ha hecho al menos una compra? ¿Abrió el último email?" Cada camino lleva a una predicción. Eso es un árbol de decisión.

    Random Forest toma cientos de árboles distintos y combina sus respuestas. El resultado es más robusto y menos propenso a overfitting que un solo árbol.

    Son los algoritmos detrás de sistemas de moderación de contenido basados en reglas aprendidas, de detección de fraude, de sistemas de recomendación básicos.

    K-Means (clustering)

    K-Means agrupa datos en K clusters. Tú dices cuántos grupos quieres (K), el algoritmo encuentra cuáles puntos de datos pertenecen a cada grupo.

    Como developer web, esto aparece en sistemas de personalización: "Usuarios que actúan como tú también compraron esto." No hay etiquetas previas — el modelo descubre los segmentos solo.

    Redes neuronales

    Aquí empieza lo que la gente llama "deep learning". Una red neuronal es una cadena de capas matemáticas que transforman una entrada (texto, imagen, audio) en una salida (una clasificación, un número, un vector).

    Lo importante para entenderlas no es la matemática — es el concepto de representación. Cada capa aprende a representar la entrada de una forma más abstracta que la anterior. La primera capa de un modelo de visión detecta bordes. La siguiente detecta formas. La siguiente detecta objetos. Ningún programador definió esas representaciones: emergieron del entrenamiento.

    Embeddings — el algoritmo que ya usas

    Los embeddings son el resultado de pasar texto (o imágenes, o audio) por una red neuronal especializada. La salida es un vector de números — típicamente de 768 a 3072 dimensiones.

    La magia es que los vectores capturan significado semántico. "Perro" y "can" producen vectores muy cercanos en ese espacio de alta dimensión. "Perro" y "hipoteca" producen vectores lejanos.

    Esto es lo que permite la búsqueda semántica: conviertes tu query en un vector, comparas contra los vectores de tu base de datos, y devuelves los más cercanos. No importa si el usuario escribió "gato" y el documento dice "felino" — los vectores están cerca.

    Transformers — la arquitectura detrás de los LLMs

    Un Transformer es una arquitectura de red neuronal diseñada para procesar secuencias. El mecanismo clave se llama "atención" (attention): permite que el modelo, al procesar una palabra, preste atención a otras palabras del contexto según su relevancia.

    "El banco estaba lleno de peces" vs "El banco rechazó mi solicitud". La misma palabra "banco", significado completamente distinto. El mecanismo de atención resuelve esto mirando el contexto completo de la frase.

    GPT, Claude, Llama y Gemini usan Transformers como arquitectura base. Los modelos de embeddings de OpenAI también son Transformers, pero optimizados para producir buenas representaciones vectoriales en lugar de generar texto.

    Cuándo le importan al developer web

    No necesitas un data scientist para beneficiarte de ML. Estas son las integraciones más comunes en proyectos web reales. Puedes ver más ejemplos aplicados en el blog de Dominicode.

    Búsqueda semántica. Reemplaza o complementa la búsqueda por palabras clave. Los embeddings convierten queries y documentos en vectores, y una base de datos vectorial (Pinecone, pgvector, Supabase Vector) hace el matching por similitud coseno.

    Moderación de contenido. Clasifica si un texto es tóxico, si una imagen es apropiada, si un comentario viola normas. HuggingFace tiene modelos de clasificación listos para usar via API — zero setup del lado del ML.

    Recomendaciones. Clustering de usuarios por comportamiento o embeddings de productos para "productos similares". No necesitas construir un sistema de recomendación desde cero — embeddings + similitud coseno es suficiente para empezar.

    Extracción de información. Parsear emails, facturas, formularios en lenguaje natural. Un LLM con un prompt bien estructurado hace esto mejor que cualquier regex que vayas a escribir.

    TypeScript en la práctica

    Aquí es donde todo esto se vuelve concreto. No vas a implementar K-Means. Vas a llamar a una API que usa K-Means internamente. Pero entender qué hace el algoritmo te ayuda a saber qué esperar y qué debuggear.

    Embeddings con OpenAI

    import OpenAI from "openai";
    
    const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
    
    async function getEmbedding(text: string): Promise<number[]> {
      const response = await client.embeddings.create({
        model: "text-embedding-3-small",
        input: text,
      });
    
      return response.data[0].embedding; // Vector de 1536 dimensiones
    }
    
    // Similitud coseno entre dos vectores
    function cosineSimilarity(a: number[], b: number[]): number {
      const dot = a.reduce((sum, val, i) => sum + val * b[i], 0);
      const magA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
      const magB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
      return dot / (magA * magB);
    }
    
    // Búsqueda semántica básica
    // En producción: usar batching o rate limiting para evitar errores 429 de la API
    async function semanticSearch(query: string, documents: string[]) {
      const queryVector = await getEmbedding(query);
      const docVectors = await Promise.all(documents.map(getEmbedding));
    
      const scores = docVectors.map((vec, i) => ({
        document: documents[i],
        similarity: cosineSimilarity(queryVector, vec),
      }));
    
      return scores.sort((a, b) => b.similarity - a.similarity);
    }
    
    const docs = [
      "Cómo configurar un servidor NestJS",
      "Recetas de cocina italiana",
      "Deploying Node.js to production",
    ];
    
    const results = await semanticSearch("backend con Node", docs);
    console.log(results[0]); // { document: "Deploying Node.js...", similarity: 0.89 }
    

    Referencia oficial: OpenAI Embeddings API.

    Clasificación con HuggingFace Inference API

    const HF_TOKEN = process.env.HF_TOKEN;
    const MODEL = "cardiffnlp/twitter-roberta-base-sentiment-latest";
    
    interface ClassificationResult {
      label: string;
      score: number;
    }
    
    async function classifySentiment(text: string): Promise<ClassificationResult[]> {
      const response = await fetch(
        `https://api-inference.huggingface.co/models/${MODEL}`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${HF_TOKEN}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ inputs: text }),
        }
      );
    
      // Si el modelo lleva tiempo sin uso, la primera respuesta puede tardar
      // 20-30 segundos con { error: "Model is currently loading" } — reintentar.
      const data = await response.json();
      return data[0] as ClassificationResult[];
    }
    
    const result = await classifySentiment("Este producto es increíble!");
    // [{ label: "POSITIVE", score: 0.97 }, { label: "NEUTRAL", score: 0.02 }, ...]
    
    if (result[0].label === "NEGATIVE" && result[0].score > 0.85) {
      // Marcar para revisión manual
    }
    

    Referencia oficial: HuggingFace Inference API.

    Dos ejemplos, dos APIs reales, cero instalación de librerías de ML. El algoritmo corre en la nube. Tú consumes el resultado y construyes producto.

    Si quieres explorar estas integraciones dentro de un flujo completo — desde la idea hasta el producto funcionando — en el curso Construye con IA cubrimos exactamente esta capa: cómo conectar modelos de ML reales a una arquitectura de producto sin convertirte en data scientist. También publicamos tutoriales y ejemplos en el canal de YouTube de Dominicode.

    La decisión que cambia todo

    Entender estos algoritmos no significa que vayas a entrenar modelos. Significa que cuando eliges entre una búsqueda por palabras clave y una búsqueda semántica, sabes exactamente qué estás eligiendo y por qué.

    Significa que cuando un modelo de clasificación te devuelve un score bajo, sabes si el problema está en el modelo, en los datos de entrada, o en cómo estás interpretando el output.

    Significa que cuando alguien en tu equipo dice "usemos ML para esto", puedes hacer las preguntas correctas: ¿supervisado o no supervisado? ¿Tienes datos etiquetados? ¿Qué métrica defines como éxito?

    Los modelos los entrenan los data scientists. El producto lo construyes tú. Saber qué hay debajo del capó es lo que hace la diferencia entre un developer que consume IA y uno que la integra de forma inteligente.

    En Dominicode Labs tenemos proyectos completos donde aplicamos estas integraciones en contextos reales — búsqueda semántica, pipelines con embeddings, agentes que usan clasificadores como herramientas. Si quieres ver el código funcionando, es donde empieza.


    FAQ

    ¿Necesito saber matemáticas para usar algoritmos de machine learning como developer?

    No para usarlos, sí para entenderlos en profundidad. La mayoría de las integraciones que harás como developer web consumen modelos ya entrenados via API. Saber qué hace el algoritmo — qué tipo de problema resuelve y qué output produce — es suficiente para tomar buenas decisiones de arquitectura. Si en algún momento necesitas afinar un modelo o interpretar métricas de entrenamiento, entonces sí vale la pena profundizar en la matemática.

    ¿Cuál es la diferencia entre un LLM y un modelo de embeddings?

    Un LLM (como GPT-4 o Claude) está entrenado para generar texto: toma una secuencia de tokens y predice los siguientes. Un modelo de embeddings está optimizado para producir representaciones vectoriales del texto, capturando su significado semántico en un espacio de alta dimensión. Ambos usan arquitectura Transformer, pero con objetivos de entrenamiento distintos. Para búsqueda semántica, usa modelos de embeddings — son más baratos y específicos para esa tarea.

    ¿Cuándo debería usar TensorFlow.js en lugar de una API de ML?

    TensorFlow.js tiene sentido cuando necesitas ejecutar inferencia en el cliente (sin enviar datos al servidor, por privacidad), cuando tienes latencia muy baja como requisito, o cuando quieres evitar costos de API a escala. El tradeoff es que los modelos disponibles para el navegador son más pequeños y menos potentes. Para la mayoría de proyectos web, una API de HuggingFace o OpenAI es la opción correcta hasta que tengas una razón específica para moverse al cliente.

    ¿Qué es el overfitting y por qué le importa al developer que consume modelos?

    El overfitting ocurre cuando un modelo aprende demasiado bien los datos de entrenamiento y pierde capacidad de generalizar a datos nuevos. Como developer que consume un modelo ya entrenado, el overfitting se manifiesta como comportamiento inesperado: el modelo funciona bien en ejemplos estándar pero falla en casos edge de tu dominio específico. Si ves esto, la solución no es ajustar el código — es cambiar de modelo, hacer fine-tuning, o cambiar cómo preparas el input (prompt engineering, preprocesado de texto).

    ¿Qué base de datos debo usar para guardar y consultar embeddings?

    Depende de tu stack. Si ya usas PostgreSQL o Supabase, la extensión pgvector añade soporte nativo para búsqueda por similitud coseno sin infraestructura adicional. Si necesitas escala masiva (millones de vectores con latencia sub-50ms), Pinecone o Weaviate son las opciones especializadas. Para prototipos o proyectos pequeños, guardar vectores en memoria con una búsqueda lineal es perfectamente válido mientras no superes los 10k documentos.


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

  • Claude API: Crash Course para developers con TypeScript

    Claude API: Crash Course para developers con TypeScript

    Hace unos meses un developer me escribió frustrado. Llevaba dos días intentando integrar Claude en su app. No le funcionaba el streaming, no entendía por qué sus respuestas llegaban cortadas, y había probado tres ejemplos distintos de Stack Overflow que usaban versiones diferentes del SDK.

    El problema no era la API. Era que había empezado por el medio.

    Esta es la Claude API introducción que yo habría querido tener al principio: sin rodeos, con código real, y con el orden correcto para entender qué está pasando antes de que algo falle.

    Qué es la Claude API y por qué te importa

    Claude es el modelo de lenguaje de Anthropic. La API te da acceso directo a ese modelo desde tu código: puedes enviarle mensajes, pedirle que razone, que use herramientas externas, que responda en streaming o que procese imágenes.

    La diferencia respecto a ChatGPT para developers es principalmente la calidad del razonamiento en tareas de código complejas y el system prompt — Claude lo sigue con una precisión que cambia cómo construyes agentes.

    Setup: API key y SDK

    Primero necesitas una cuenta en console.anthropic.com. Una vez dentro, ve a API Keys y genera una nueva clave. Guárdala — no la vuelves a ver.

    Instala el SDK oficial con npm o Bun:

    npm install @anthropic-ai/sdk
    # o con Bun
    bun add @anthropic-ai/sdk
    

    Guarda la clave en una variable de entorno. Nunca en el código:

    # .env
    ANTHROPIC_API_KEY=sk-ant-...
    

    Tu primera llamada en TypeScript

    Este es el "Hello World" de la Claude API. Sin clases, sin abstracción, directo al grano:

    import Anthropic from "@anthropic-ai/sdk";
    
    const client = new Anthropic({
      apiKey: process.env.ANTHROPIC_API_KEY,
    });
    
    async function main() {
      const response = await client.messages.create({
        model: "claude-sonnet-4-6",
        max_tokens: 1024,
        messages: [
          {
            role: "user",
            content: "Explica qué es un closure en JavaScript en 2 líneas.",
          },
        ],
      });
    
      console.log(response.content[0].type === "text" ? response.content[0].text : "");
    }
    
    main();
    

    Eso es todo. Ejecutas esto y tienes una respuesta de Claude en tu terminal.

    Lo que necesitas entender de la estructura:

    • model — qué versión de Claude usas (más sobre esto abajo)
    • max_tokens — límite de tokens en la respuesta (no el total de la conversación)
    • messages — array de turnos de conversación con role: "user" o role: "assistant"

    Los conceptos que no puedes ignorar

    Modelos disponibles

    Anthropic tiene tres familias activas:

    Modelo Cuándo usarlo
    claude-sonnet-4-6 El equilibrio perfecto: velocidad + calidad. Mi default para casi todo.
    claude-haiku-4-5 Más rápido y barato. Bueno para tareas simples o llamadas en volumen.
    claude-opus-4-8 El más potente. Para tareas de razonamiento complejo donde el coste no es el problema.

    Si estás empezando, usa claude-sonnet-4-6. No pienses más.

    System prompt vs User message

    El system es la personalidad y las instrucciones permanentes de Claude. El user es lo que cambia en cada turno.

    const response = await client.messages.create({
      model: "claude-sonnet-4-6",
      max_tokens: 1024,
      system: "Eres un reviewer de código senior. Responde siempre en español. Sé directo y señala el problema antes de proponer la solución.",
      messages: [
        {
          role: "user",
          content: "Revisa esta función: function add(a, b) { return a - b; }",
        },
      ],
    });
    

    El system prompt es donde ocurre la mayor parte de la magia cuando construyes agentes. Si quieres ver cómo llevamos esto a proyectos reales con Claude Code, en el curso Construye con IA cubrimos exactamente eso: de la idea al producto con agentes que siguen instrucciones de producción.

    Tokens: lo que cuesta dinero

    Un token es aproximadamente 0,75 palabras en inglés (algo menos en español). La API te cobra por input_tokens (lo que envías) y output_tokens (lo que Claude responde).

    Después de cada llamada puedes ver el uso:

    console.log(response.usage);
    // { input_tokens: 48, output_tokens: 312 }
    

    max_tokens limita la respuesta, no la llamada completa. Si pones max_tokens: 100 y la respuesta necesita 200 tokens, Claude cortará el texto a mitad. Es uno de los errores más comunes al empezar.

    ¿Cómo implementar streaming con la Claude API en TypeScript?

    Sin streaming, esperas a que Claude termine de generar toda la respuesta antes de recibirla. Con streaming, recibes los tokens a medida que se generan — igual que ves escribir a Claude en el chat web.

    Para UX en tiempo real, el streaming no es opcional. Es lo que distingue una app que se siente viva de una que "se congela" tres segundos antes de mostrar algo. En los proyectos de agentes que construimos en Labs, migrar de llamada síncrona a streaming eliminó la necesidad de un loader — los usuarios percibieron la respuesta como inmediata sin que cambiáramos nada más.

    import Anthropic from "@anthropic-ai/sdk";
    
    const client = new Anthropic({
      apiKey: process.env.ANTHROPIC_API_KEY,
    });
    
    async function streamResponse() {
      const stream = await client.messages.create({
        model: "claude-sonnet-4-6",
        max_tokens: 1024,
        stream: true,
        messages: [
          {
            role: "user",
            content: "Escribe un test unitario en TypeScript para una función que suma dos números.",
          },
        ],
      });
    
      for await (const event of stream) {
        if (
          event.type === "content_block_delta" &&
          event.delta.type === "text_delta"
        ) {
          process.stdout.write(event.delta.text);
        }
      }
    
      console.log("\n--- Stream completado ---");
    }
    
    streamResponse();
    

    El loop for await itera sobre los eventos del stream. El tipo que te importa es content_block_delta con delta.type === "text_delta" — ahí está el texto.

    ¿Qué es el tool use en Claude API y cómo funciona?

    Tool use (o function calling) permite que Claude llame a funciones definidas por ti. Claude decide cuándo usarlas y con qué argumentos. Tú ejecutas la función y le devuelves el resultado.

    El siguiente ejemplo define una herramienta get_weather ficticia:

    const response = await client.messages.create({
      model: "claude-sonnet-4-6",
      max_tokens: 1024,
      tools: [
        {
          name: "get_weather",
          description: "Obtiene el tiempo actual para una ciudad.",
          input_schema: {
            type: "object",
            properties: {
              city: {
                type: "string",
                description: "El nombre de la ciudad.",
              },
            },
            required: ["city"],
          },
        },
      ],
      messages: [
        {
          role: "user",
          content: "¿Qué tiempo hace en Madrid ahora mismo?",
        },
      ],
    });
    
    // Si Claude quiere usar la herramienta, el stop_reason será "tool_use"
    if (response.stop_reason === "tool_use") {
      const toolUse = response.content.find((b) => b.type === "tool_use");
      console.log("Claude quiere llamar a:", toolUse?.name);
      console.log("Con argumentos:", toolUse?.input);
      // Aquí ejecutarías la función real y devolverías el resultado a Claude
    }
    

    Esto es la base de cualquier agente. Claude no ejecuta código — tú lo ejecutas y le informas del resultado. El loop de razonamiento lo controla Claude; la ejecución la controlas tú. Si quieres ver cómo este patrón escala a un pipeline completo — desde un ticket de Jira hasta el deploy —, tienes el ejemplo en el post sobre automatizar el proceso de desarrollo con IA.

    Errores comunes al empezar

    Rate limits. La API tiene límites por minuto tanto en requests como en tokens. Si los golpeas, recibes un 429. Solución: exponential backoff o usar Haiku para prototipos de alto volumen.

    Context window agotado. Cada modelo tiene un límite de tokens totales en conversación (input + output). Sonnet 4.6 tiene 200K tokens de context window — es enorme, pero si metes archivos enteros en cada llamada, lo llenas. Sé selectivo con lo que incluyes en el contexto.

    Formato de mensajes incorrecto. El array messages debe alternar user y assistant. No puedes tener dos mensajes de user seguidos sin un assistant entre medias. Eso devuelve un error 400.

    max_tokens demasiado bajo. Si la respuesta se corta, sube max_tokens. El valor por defecto no existe — es un parámetro obligatorio. Empieza con 1024 y ajusta según lo que necesites.

    Variables de entorno no cargadas. Si ves AuthenticationError, casi siempre es que ANTHROPIC_API_KEY no está disponible en el proceso. Verifica con console.log(process.env.ANTHROPIC_API_KEY) antes de depurar nada más.

    Qué explorar después

    Una vez tienes la llamada básica y el streaming funcionando, estos son los siguientes pasos lógicos:

    Vision. Puedes enviar imágenes en el array content y Claude las analiza. Útil para screenshots, diagramas, facturas.

    Embeddings. Anthropic no tiene embeddings propios en la API, pero Claude funciona muy bien combinado con embeddings de OpenAI o Cohere para búsqueda semántica.

    Batch API. Para procesar cientos de prompts sin necesidad de respuesta en tiempo real. Hasta un 50% más barato que llamadas individuales.

    Workbench de Anthropic. En console.anthropic.com tienes un playground para probar prompts, comparar modelos y ver el uso de tokens antes de escribir una sola línea de código. Es la herramienta que más uso al diseñar system prompts.

    Multiturno real. Construir una conversación que mantenga contexto entre turnos requiere gestionar el array messages manualmente — añadir cada respuesta de Claude como role: "assistant" y cada input del usuario como role: "user". No hay estado en la API.

    Si quieres ver tool use aplicado a un workflow de code review automático antes de un PR, tienes el flujo completo en el post sobre agentic code review con Claude Code.

    Si tuvieras que elegir solo un área para explorar después del streaming, elige Vision — es el salto de ROI más rápido y el que más impacto tiene en una demo.


    FAQ

    ¿Necesito tarjeta de crédito para empezar?
    Sí. Anthropic requiere un método de pago para activar la API, pero tiene un tier de prueba con crédito gratuito. Puedes hacer cientos de llamadas de desarrollo sin pagar nada en los primeros días.

    ¿Cuál es la diferencia entre la API de Claude y Claude.ai?
    Claude.ai es el producto de consumo (el chat web). La API es el acceso programático al modelo. Tienen facturación y cuentas separadas. Una suscripción a Claude.ai no te da acceso a la API.

    ¿Cuánto cuesta en producción?
    Depende del modelo y el volumen. Claude Sonnet 4.6 está alrededor de $3 por millón de input tokens y $15 por millón de output tokens — verifica siempre en anthropic.com/pricing antes de hacer estimaciones de arquitectura, los precios se actualizan con cada generación de modelo.

    ¿Puedo usar la API en el frontend directamente?
    Técnicamente sí, pero nunca deberías. La API key quedaría expuesta en el cliente. Siempre llama a la API desde un backend o un serverless function que tú controlas.

    ¿Qué pasa si Claude no termina la respuesta y stop_reason no es end_turn?
    Si stop_reason es max_tokens, la respuesta se cortó por el límite que pusiste. Si es tool_use, Claude quiere ejecutar una herramienta. Si es stop_sequence, alcanzó una secuencia de parada que definiste. Valida siempre stop_reason en producción.


    Si quieres ver todo esto aplicado en un proyecto real — no en ejemplos de tutorial sino en un producto con usuarios — en Dominicode Labs tenemos el código de los proyectos que construimos en directo, incluyendo agentes con tool use y streaming. Es donde llevamos la teoría a producción.

    Y si prefieres el formato video con más ejemplos en directo, en el canal de YouTube de Dominicode cubrimos estas integraciones con frecuencia.


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