TypeScript para ingenieros de ML de Python: crea un servicio de agentes

Traducción automática Este artículo se tradujo automáticamente a partir de la versión original en inglés.

Esta es una guía rápida de incorporación para ingenieros de Python con experiencia que necesitan desplegar servicios de AI en TypeScript y Node. Está escrita para ingenieros de ML, científicos de datos y desarrolladores backend que no necesitan un curso introductorio de JavaScript.

Yo mismo hice esa incorporación durante los últimos meses, después de trabajar con Python y Java. La mayoría de las guías que encontré empezaban con programación básica o trabajo con el DOM del frontend. Este artículo parte de conceptos de servicios de Python. Al final, podrás relacionar un stack de servicios de Python con TypeScript y reconocer los hábitos de Python que provocan errores en JavaScript. El ejemplo conductor sigue un servicio de agentes con streaming, desde el esquema hasta el despliegue.

Resumen: Instala Node 24 y pnpm. Después, usa los comandos pnpm del repositorio. Ejecuta pnpm demo para el ejemplo offline, pnpm dev:api y pnpm dev:worker para desarrollo, y pnpm check antes de hacer un commit. No necesitas ejecutar node, tsx ni el comprobador de TypeScript por tu cuenta. Los scripts del paquete lo hacen.

El servicio usa Zod, Hono, Drizzle, Vitest y Biome. Cubren buena parte del mismo terreno que pydantic, FastAPI, SQLAlchemy, pytest y Ruff. Mantén los cálculos numéricos pesados en Python. Usa este servicio de TypeScript para la orquestación, HTTP y streaming.

Todo lo que aparece aquí es un archivo de slavadubrov/typescript-agent-service, el repositorio complementario publicado con este artículo. Contiene una API HTTP, dos versiones del mismo agent loop, historial de ejecuciones en Postgres, un worker y un servidor MCP. pnpm install && pnpm demo ejecuta la ruta offline de agente HTTP/SSE y el cálculo de barrido del worker sin necesidad de una clave de API.

Aquí solo cubro backend y trabajo con AI. No hay React. Tampoco hay un bundler para el navegador.


Ejecuta primero el repositorio complementario

Instala Node 24 y pnpm siguiendo las instrucciones oficiales de instalación de pnpm. Después, clona el repositorio complementario y ejecuta:

pnpm install
pnpm demo
pnpm check

pnpm demo prueba el handler HTTP, el agent loop y el stream SSE con un modelo simulado. También llama al cálculo runSweep del worker. No inicia el proceso del worker ni su cola de base de datos. La demo no necesita clave de API, base de datos ni Docker. pnpm check ejecuta el comprobador de tipos, el linter, el formateador y los tests.

Para ejecutar la API y el worker reales:

cp .env.example .env          # add OPENAI_API_KEY or an OpenAI-compatible URL
pnpm db:up                    # start Postgres in Docker
pnpm db:push                  # create the database schema
pnpm dev:api                  # API at http://localhost:8080
pnpm dev:worker               # run this in a second terminal

Esos son los comandos que uso en el resto del artículo. El repositorio oculta los comandos de nivel inferior de Node y TypeScript tras scripts pnpm con nombre, igual que un proyecto de Python podría ocultar comandos uv run tras objetivos make. No mezcles npm install en este repositorio de pnpm. Usa pnpm install para que pnpm-lock.yaml siga siendo el único lockfile.


Qué hacen Node, npm, pnpm, TypeScript y tsx

Los nombres parecidos ocultan funciones distintas:

  • JavaScript es el lenguaje.
  • Node.js es el runtime, aproximadamente el CPython de JavaScript. Instala Node 24 para este proyecto.
  • El registro de npm es el índice de paquetes, aproximadamente PyPI. El comando npm viene con Node y puede instalar paquetes desde ese registro.
  • pnpm es el package manager elegido por este repositorio. Instala paquetes desde el registro de npm, gestiona el workspace del monorepo y ejecuta los comandos declarados en package.json.
  • package.json es el manifiesto del proyecto, lo más parecido a pyproject.toml. Su sección scripts asigna nombres como demo, check y dev:api a comandos más largos.
  • TypeScript es JavaScript con tipos estáticos. Su comando tsc comprueba esos tipos. El repositorio lo ejecuta mediante pnpm check o pnpm typecheck.
  • tsx ejecuta archivos .ts sin una compilación independiente. Los scripts de desarrollo usan su modo watch para reiniciar la API o el worker cuando cambia el código fuente. En esta guía no lo invocas directamente.

En este repositorio, ejecuta los scripts pnpm. Node es el runtime dentro de esos scripts, y el Dockerfile proporcionado se encarga de producción.


El stack, relacionado con Python

Dos columnas relacionan cada aspecto de un servicio de AI en Python con su sustituto en TypeScript. Seis sustituciones ocupan la misma posición, mientras un panel específico destaca las diferencias semánticas en validación, colas y comprobación de tipos.Dos columnas relacionan cada aspecto de un servicio de AI en Python con su sustituto en TypeScript. Seis sustituciones ocupan la misma posición, mientras un panel específico destaca las diferencias semánticas en validación, colas y comprobación de tipos.

La mayor parte de la relación es aburrida, lo cual es una buena noticia. Hay tres filas que no lo son:

AspectoPythonTypeScriptPor qué no es un reemplazo directo
ValidaciónpydanticzodEl esquema es la fuente de verdad. El tipo se genera a partir de él, no al revés
Comprobación de tiposmypyTypeScript (pnpm typecheck)Ambos comprueban el código fuente sin validar los datos que llegan en runtime
Cola de trabajoscelery + Redisbullmq (cola respaldada por Redis) o SQLPostgres puede implementar una cola at-least-once. Quizá no necesites un broker

El repositorio complementario utiliza cuatro bibliotecas que merece la pena explicar.

Hono para la capa HTTP

Express y Fastify son alternativas centradas en Node. Hono usa las APIs Request y Response, estándar de la Web y proporciona adaptadores para runtimes de Node y serverless. Esa portabilidad resulta útil para esta pequeña API con streaming, por lo que elegí Hono.

Drizzle para SQL

Drizzle mantiene el esquema en TypeScript y no requiere un paso de generación de cliente. También permite usar SQL directamente cuando el query builder no puede expresar limpiamente una cláusula de Postgres. Elegiría Prisma cuando su cliente generado y las herramientas de su entorno encajen mejor con el equipo.

Biome para linting y formateo

Biome se encarga del linting, el formateo y la ordenación de imports con un único binario y un único archivo de configuración. Mantén ESLint si el proyecto depende de reglas personalizadas que Biome no ofrece.

Vitest para los tests

Vitest ejecuta los tests .ts del repositorio complementario sin una configuración de transformación independiente.


Lee la sintaxis de TypeScript utilizada a continuación

Mantén esta tabla junto a los ejemplos del servicio como referencia.

TypeScriptPython / nota
(x) => expressionfunción anónima con cuerpo de expresión, similar a lambda x: expression
(x) => { statements }función anónima con cuerpo de sentencias
async (x) => { statements }función anónima asíncrona
const { model, seqLen } = requestextrae las propiedades model y seqLen de request
const [first] = xsfirst = xs[0]. Produce undefined, no IndexError, cuando está vacío
{ type: "error", message }{"type": "error", "message": message}. Un nombre sin más se convierte en ese campo
text ${x}f-string
cond ? a : ba if cond else b
const / letAmbos vinculan un nombre. const impide reasignarlo, mientras que let lo permite
exporthace que un nombre se pueda importar
switch / casematch, salvo que los casos continúen si no terminan en break o return
for awaititerar sobre un generador asíncrono
i++incrementa y devuelve el valor anterior
/^https?$/un literal de expresión regular; no hace falta re.compile
T[], Map<K, V>list[T], dict[K, V]

Usa const salvo que el binding tenga que cambiar. Usa let para un contador, acumulador u otro binding que vayas a reasignar.

Una traducción breve de enum

Para estados representados por strings, este repositorio utiliza un objeto y un tipo unión de strings inferido:

const Status = { Queued: "queued", Running: "running" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "queued" | "running"

El objeto proporciona Status.Queued mientras se ejecuta el programa. La línea type permite únicamente "queued" o "running" durante la comprobación de tipos. Juntos desempeñan las dos funciones de esta declaración de Python:

from enum import Enum

class Status(str, Enum):
    QUEUED = "queued"
    RUNNING = "running"

Solo necesitas reconocer el patrón. as const conserva los valores del objeto como strings exactos, en lugar de ampliarlos a cualquier string.


Las siete diferencias semánticas que hacen perder tiempo

La tabla de sintaxis te permite seguir los ejemplos. Estas diferencias semánticas son las que convierten los hábitos de Python en errores.

1. Los arrays y objetos vacíos son truthy

La idea de Python de que «un contenedor vacío es falsy» es el hábito que peor se traslada. if (results) es true para un array vacío. Escribe if (results.length).

2. null y undefined son diferentes

null suele indicar una ausencia intencionada. undefined suele significar que falta un valor o que no se ha asignado, aunque el código puede asignarlo explícitamente. El código de las bibliotecas devuelve undefined constantemente. La diferencia importa cuando escribes un valor predeterminado. || sustituye el lado derecho siempre que el izquierdo sea falsy. Esto incluye 0, "" y false. ?? solo sustituye null y undefined. Por tanto, 0 || 10 es 10, mientras que 0 ?? 10 es 0. Esa diferencia es la razón por la que un tamaño de lote de cero se convierte silenciosamente en diez.

3. Un bloque catch recibe unknown

No existe except ValueError:. Un único bloque catch recibe cualquier cosa. Como JavaScript permite lanzar un string, un número o null, TypeScript tipa el valor capturado como unknown, su tipo «podría ser literalmente cualquier cosa» bajo strict. El repositorio complementario activa strict, y los proyectos nuevos deberían hacerlo en general. Para inspeccionar el error, acota primero el valor:

try {
    await risky();
} catch (error) {
    // `error` is `unknown` until you prove otherwise. This line is the
    // TypeScript equivalent of `except ValueError as e:` and it is not
    // optional.
    const message = error instanceof Error ? error.message : String(error);
}

4. Las Promises empiezan inmediatamente

Llamar a una función async empieza a ejecutar su cuerpo y devuelve una promise. Un objeto coroutine de Python no hace nada hasta que lo esperas o lo programas. Promise.all se parece a asyncio.gather. Promise.allSettled se parece a gather(..., return_exceptions=True), salvo que cada resultado está envuelto como { status, value } o { status, reason }.

Node gestiona la planificación del runtime. Mantiene el proceso activo mientras existan handles activos o peticiones, como timers y sockets. Una promise pendiente ordinaria por sí sola no mantiene vivo Node. No envuelvas el programa en asyncio.run. En un módulo ES, puedes usar await en el nivel superior cuando el arranque deba esperar una operación asíncrona.

5. JavaScript tiene un único tipo numérico ordinario

El tipo number de JavaScript almacena valores como números de coma flotante de 64 bits, aproximadamente igual que el float de Python. El estándar técnico de este formato se denomina IEEE 754. Los valores decimales son aproximados, por lo que 0.1 + 0.2 no es exactamente 0.3, y los enteros solo son exactos hasta 2**53 - 1, es decir, 9,007,199,254,740,991.

Mantén los IDs de 64 bits como strings en los límites del servicio. Convertir un bigint de Postgres a un number de JavaScript puede redondearlo. Para enteros exactos más grandes, JavaScript proporciona el tipo separado BigInt, que no se puede mezclar con los números ordinarios.

6. Usa Map cuando necesites un diccionario al estilo de Python

En JavaScript, {} crea un objeto. Los objetos suelen representar registros con campos con nombre:

const request = { model: "gpt-5", seqLen: 4096 };
console.log(request.model);

Un objeto no es una tabla limpia de clave-valor como un dict de Python. Hereda algunos nombres del propio JavaScript. Esto puede producir un resultado inesperado:

const tools: Record<string, unknown> = {};

tools["constructor"]; // a built-in function, not a missing value
Object.hasOwn(tools, "constructor"); // false

Si un string externo selecciona un campo de un objeto, llama a Object.hasOwn antes de leerlo. Si necesitas un diccionario de propósito general, usa Map. Map se parece más al dict de Python: una clave solo existe cuando tu código la añade.

const tools = new Map<string, unknown>();
tools.get("constructor"); // undefined

7. Incluye la extensión en los imports relativos

Un archivo de código JavaScript que comparte código con otros archivos se denomina módulo. Este proyecto utiliza el formato de módulos moderno, ES modules, normalmente abreviado como ESM. ES significa ECMAScript, el nombre formal del lenguaje JavaScript. En la práctica, ESM es la sintaxis import y export utilizada en todo el proyecto.

En un import relativo, Node requiere el nombre de archivo exacto. No adivina si ./env significa ./env.ts o ./env.js:

import { loadEnv } from "./env.ts";

Los imports desde paquetes instalados o del workspace siguen utilizando el nombre del paquete, sin extensión de archivo:

import { z } from "zod";
import { runAgent } from "@agent/core";

Zod es pydantic con la flecha invertida

En pydantic declaras una clase y obtienes un validador. En Zod declaras un validador y deduces el tipo a partir de él. La misma fuente única de verdad, pero en dirección opuesta.

A partir de packages/schemas/src/env.ts:

import { z } from "zod"; // `z` is Zod's whole API, the way `pd` is pandas

const EnvSchema = z.object({
    PORT: z.coerce.number().int().positive().default(8080),
    // See the note below: a bare z.url() would accept "localhost:8000".
    OPENAI_BASE_URL: z
        .url({ protocol: /^https?$/ })
        .default("https://api.openai.com/v1"),
    DATABASE_URL: z.string().optional(),
});

export type Env = z.infer<typeof EnvSchema>;

z.infer<typeof EnvSchema> extrae un tipo estático del esquema en runtime. z.coerce.number() resuelve el hecho de que cada valor definido en process.env (el os.environ de Node) sea un string. Cumple el mismo papel que la coerción de configuración numérica de pydantic, aunque los strings exactos que acepta cada uno difieren. Sigue el patrón pydantic-settings y se ejecuta una vez durante el arranque. Un entorno no válido produce así un error de arranque legible en lugar de un TypeError dentro de un handler.

Un z.url() sin más acepta localhost:8000. El estándar de URL trata todo lo que precede a los dos puntos como el esquema. Por tanto, interpreta localhost: como un protocolo llamado «localhost» y acepta el string. El valor llega después al cliente HTTP y falla con menos contexto. La validación del esquema adelanta los fallos, pero aplicará un esquema permisivo si eso es lo que has escrito.

Zod 4 también incluye z.toJSONSchema, por lo que este proyecto no necesita la dependencia zod-to-json-schema habitual en tutoriales antiguos. Esto importa cuando un esquema tiene que alimentar a tres consumidores, que es el tema de la sección «Una herramienta, tres consumidores».


El servicio

Un esquema de Zod alimenta un loop escrito a mano, el Vercel AI SDK y un publicador MCP. Los dos adaptadores del loop sirven a la API de Hono; el publicador sirve al proceso MCP, y la API y el worker comparten una tabla de ejecuciones en Postgres.Un esquema de Zod alimenta un loop escrito a mano, el Vercel AI SDK y un publicador MCP. Los dos adaptadores del loop sirven a la API de Hono; el publicador sirve al proceso MCP, y la API y el worker comparten una tabla de ejecuciones en Postgres.

El servicio de demostración dimensiona despliegues de LLM. Una herramienta consulta las constantes de arquitectura de un modelo. La otra estima su huella de KV cache: la memoria de la GPU utilizada para almacenar las keys y values de atención de las peticiones en curso. Ambas herramientas realizan deliberadamente una aritmética sencilla. No necesitan red y siempre devuelven la misma respuesta. Esto hace que el servicio se pueda probar sin una clave de API. El estimador de KV cache también se publica mediante el Model Context Protocol (MCP), para que otros clientes de AI puedan llamarlo.

typescript-agent-service/
├── apps/
│   ├── api/           @agent/api: Hono API, streams Server-Sent Events (below)
│   ├── worker/        polls Postgres for long-running jobs
│   └── mcp/           MCP server: exposes one tool to outside AI clients
├── packages/
│   ├── schemas/       package name @agent/schemas: env, API, and tool schemas
│   ├── agent-core/    package name @agent/core: the loop (twice), tools, storage
│   └── observability/ Pino logging, OpenTelemetry tracing
├── pnpm-workspace.yaml
└── package.json

pnpm-workspace.yaml es el archivo que declara el workspace. Los paquetes internos reciben un nombre con scope, como @agent/core, donde el prefijo @agent/ es una convención de nombres, no una característica del lenguaje. Cada paquete declara su punto de entrada público en package.json. Ese límite de paquete no depende del comando que inicie la aplicación.

Este workspace privado dirige esas entradas al código fuente de .ts porque todos los consumidores forman parte del mismo repositorio. Los paquetes npm públicos normalmente publican JavaScript junto con declaraciones de tipos .d.ts, para que los consumidores habituales de Node no necesiten el runner de TypeScript ni la configuración de build del autor del paquete.


Escribe el loop de herramientas a mano, una vez

Un agent loop escrito a mano ensambla tool calls en streaming, los valida, ejecuta las herramientas válidas y devuelve las entradas no válidas al historial de mensajes como un error de herramienta. Su stream tipado AgentEvent alimenta la ruta HTTP y los tests; la ruta guarda el resultado final después del streaming.Un agent loop escrito a mano ensambla tool calls en streaming, los valida, ejecuta las herramientas válidas y devuelve las entradas no válidas al historial de mensajes como un error de herramienta. Su stream tipado AgentEvent alimenta la ruta HTTP y los tests; la ruta guarda el resultado final después del streaming.

Los frameworks de agentes con tool calling encapsulan el mismo loop básico:

  1. Llama al modelo con las definiciones de las herramientas.
  2. Valida y ejecuta las herramientas solicitadas.
  3. Añade los resultados a los mensajes.
  4. Vuelve a llamar al modelo.

Escribe este loop una vez. Así podrás defender el comportamiento del framework como una decisión de ingeniería.

El loop es un async function*, un generador asíncrono, exactamente la forma de un async def de Python con yield. La ruta HTTP itera ese generador, convierte cada evento en un frame de Server-Sent Events y acumula el texto. Cuando termina el stream, la ruta llama una vez a storage.createRun con el texto final. Los tests invocan el loop por separado y recopilan sus eventos en un array. Un frame SSE es un fragmento de una respuesta HTTP de larga duración. La sección siguiente explica el formato.

El loop tiene dos salidas normales. Una respuesta sin tool calls produce done con stopReason: "stop". Si el modelo sigue solicitando herramientas hasta maxSteps, el generador también termina y produce done con stopReason: "max_steps". Trata ese resultado como completado pero truncado: el texto acumulado puede estar incompleto.

A partir de packages/agent-core/src/loop.ts:

let text = ""; // the assistant text produced during this model step
// Keyed by the `index` field, because a streamed response interleaves
// fragments of several parallel tool calls and only `index` is present
// on every fragment. `id` and `name` arrive once, `arguments` arrives
// in pieces. `a?.b` below reads `b` only if `a` exists, and gives back
// `undefined` instead of throwing if it doesn't.
const partial = new Map<number, PartialToolCall>();

for await (const chunk of stream) {
    const choice = chunk.choices[0];
    if (!choice) continue;

    if (choice.delta.content) {
        text += choice.delta.content;
        yield { type: "text", delta: choice.delta.content };
    }

    for (const fragment of choice.delta.tool_calls ?? []) {
        const slot = partial.get(fragment.index) ?? { id: "", name: "", args: "" };
        if (fragment.id) slot.id = fragment.id;
        if (fragment.function?.name) slot.name = fragment.function.name;
        if (fragment.function?.arguments) slot.args += fragment.function.arguments;
        partial.set(fragment.index, slot);
    }
}

La variable local del loop text contiene un paso del modelo. El loop la utiliza en el mensaje del assistant para el siguiente paso o en el evento final done. No es el acumulador de nivel de ruta que se persiste después.

El mapa partial es la parte que ocultan los frameworks. El SDK expone fragmentos string de los argumentos de la función, que pueden dividir el JSON serializado en posiciones arbitrarias. Además, varias llamadas paralelas pueden intercalarse. El repositorio tiene un test que divide {"model":"llama-3.1-8b",...} en cuatro chunks.

Lo segundo que merece la pena escribir por tu cuenta es qué ocurre cuando falla la validación:

// `tool.parse` is this repo's wrapper, not Zod's. Zod's own `.parse` throws and
// `.safeParse` returns `{ success, data, error }`; this returns
// `{ ok: true, value }` on success and `{ ok: false, error }` on failure, so no
// caller has to catch.
const parsed = tool.parse(raw);
if (!parsed.ok) {
    return {
        ok: false,
        value: { error: `Invalid arguments: ${parsed.error}` },
        parsedArgs: raw,
    };
}

Antes de ejecutar, la búsqueda puede no encontrar la herramienta, JSON.parse puede rechazar los argumentos o Zod puede rechazar su forma. Cada fallo se convierte en un mensaje que el modelo puede leer. Durante la ejecución, un ToolError esperado también se convierte en un resultado de herramienta para que el modelo pueda corregir la llamada. Una excepción inesperada se propaga a la ruta de error HTTP en lugar de presentarse como un fallo de dominio. z.prettifyError convierte el árbol de incidencias de Zod en un mensaje sobre el que el modelo puede actuar, en vez de en un stack trace.

strict: true en una definición de función de OpenAI pide al proveedor que limite la decodificación al esquema. No tiene relación con la opción strict del tsconfig de TypeScript. Se parece al guided decoding de vLLM, aunque los esquemas compatibles y los detalles de enforcement difieren. Elimina un modo de fallo, pero un endpoint self-hosted puede ignorar la opción. Los argumentos también deben superar JSON.parse.

El loop llama a /chat/completions porque el repositorio complementario apunta a servidores compatibles con OpenAI. vLLM, SGLang y Ollama documentan ese endpoint, por lo que OPENAI_BASE_URL puede dirigir el mismo cliente a cualquiera de ellos. La cobertura de Responses API difiere entre ellos y cambia según la release. Si controlas ambos lados, consulta la página de compatibilidad actual del servidor antes de elegir entre las dos APIs.


Después cambia al AI SDK y entiende qué has intercambiado

Para proyectos posteriores, usaría el Vercel AI SDK. El repositorio complementario implementa el mismo agente dos veces para que el intercambio sea visible. Ambas versiones emiten el mismo stream AgentEvent, por lo que la capa HTTP no puede distinguirlas.

El repositorio complementario fija AI SDK 7.0.42 en packages/agent-core/package.json. La implementación del framework está en packages/agent-core/src/loop-ai-sdk.ts:

const result = streamText({
    model: provider.chatModel(options.model),
    prompt: options.message,
    tools: aiSdkTools,          // Zod schemas passed straight through
    stopWhen: stepCountIs(options.maxSteps ?? 6),
});

for await (const part of result.fullStream) {
    switch (part.type) {
        case "text-delta": yield { type: "text", delta: part.text }; break;
        case "tool-call":  yield { type: "tool_call", callId: part.toolCallId, /* ... */ }; break;
        // ...
    }
}

El SDK elimina cinco fragmentos de código de la aplicación:

  • el acumulador de fragmentos
  • JSON.parse y su ruta de error
  • la llamada que ejecuta Zod sobre los argumentos parseados
  • el ensamblado de mensajes específico del proveedor
  • el contador de pasos

stopWhen acepta varias condiciones, incluido un límite de pasos o un tool call específico. El loop for await no cambia cuando cambia la política de parada.

Lo que pierdes es el control directo sobre los fallos de validación. El loop escrito a mano decide qué ve el modelo después de rechazar una llamada. El SDK expone la opción experimental experimental_repairToolCall para un callback de reparación personalizado. El repositorio complementario actual no configura esa opción, por lo que depende del tratamiento predeterminado del SDK para las llamadas no válidas.

El intercambio también funciona en la otra dirección. En la versión con el SDK, un cambio de proveedor queda localizado en el adaptador del proveedor. Aun así, requiere el paquete del proveedor correspondiente, credenciales, configuración y tests de integración. En el loop directo, el tratamiento de las peticiones y del stream específico del proveedor es código tuyo que debes modificar.

Yo escribo el loop a mano en el primer proyecto y uso el SDK en los siguientes. Esa lección se paga una sola vez. La alternativa es leer por primera vez los internals de un framework mientras falla en producción.


Streaming sobre HTTP: Hono y SSE

Las rutas de Hono se parecen a las de FastAPI. La única adición es zValidator, que hace el trabajo que FastAPI obtiene gratis de las anotaciones de tipos de la firma de un handler. El c del handler siguiente es el contexto de petición de Hono, el objeto que FastAPI distribuye entre tus parámetros. deps es un conjunto de dependencias con las que se construye la aplicación, en lugar de importarlas directamente. runAgent es una de ellas, y la sección de tests muestra qué ventajas aporta.

A partir de apps/api/src/app.ts:

app.post("/v1/chat", zValidator("json", ChatRequestSchema), (c) => {
    const body = c.req.valid("json");
    const log = deps.logger.child({ route: "chat" });

    return streamSSE(c, async (stream) => {
        let text = "";
        try {
            await withSpan(
                "agent.run",
                { "agent.max_steps": body.maxSteps },
                async () => {
                    for await (const event of deps.runAgent({
                        message: body.message,
                        maxSteps: body.maxSteps,
                    })) {
                        if (event.type === "text") text += event.delta;

                        await stream.writeSSE({
                            event: event.type,
                            data: JSON.stringify(event),
                        });
                    }
                },
            );
        } catch (error) {
            log.error({ err: error }, "agent run failed");
            await stream.writeSSE({
                event: "error",
                data: JSON.stringify({
                    type: "error",
                    message: "Agent run failed",
                }),
            });
            return;
        }

        await deps.storage.createRun({
            kind: "chat",
            status: "succeeded",
            input: { message: body.message },
            output: { text },
        });
    });
});

La ruta trata actualmente todos los generadores que terminan con normalidad de la misma manera. Persiste status: "succeeded" después de cualquier generador que llegue al final, incluido uno cuyo evento final tenga stopReason: "max_steps". Por tanto, el resultado de alcanzar el límite se marca como completado pero truncado o parcial, no como un fallo ya persistido. El código de producción debería inspeccionar el evento done y aplicar una política explícita. Por ejemplo, podría usar un estado truncado distinto o una ruta de revisión/reintento antes de comunicar el éxito.

zValidator valida el body y proporciona a c.req.valid("json") el tipo que produce el esquema. Si lo omites, el body queda tipado como any, la opción de escape de TypeScript, donde cualquier acceso a propiedades compila y no se comprueba. Eso desactiva la ventaja de seguridad de tipos del esquema.

Esta ruta usa Server-Sent Events en lugar de WebSockets. El servidor mantiene abierta una respuesta HTTP mientras escribe frames event: <name> y data: <json>, y después la cierra tras el evento final. El tráfico va del servidor al cliente, lo que encaja con este stream del agente. Un WebSocket añadiría mensajería bidireccional y una actualización de protocolo que esta ruta no necesita.

Un fallo a mitad del stream cambia la notificación de errores HTTP. Una vez enviado el primer frame con estado 200, el servidor no puede sustituir esa respuesta por un 500. El bloque catch registra el error capturado, envía al cliente un evento de error constante y termina. Ese return es importante: solo un stream completado correctamente llega a storage.createRun. Un generador que emite done, incluido max_steps, es un stream completado con normalidad y sí llega a esa escritura.

Un test cubre esta ruta. Un generador produce un delta de texto y después lanza una excepción. La respuesta sigue siendo 200 y su último frame es un evento error con el mensaje constante Agent run failed. El logger conserva el error capturado para el diagnóstico en el servidor. Cualquier cliente que solo compruebe el código de estado informará de éxito en una ejecución fallida.

app.ts toma dos decisiones menores que merece la pena explicar. Trata /healthz como un endpoint de liveness, por lo que esa ruta no toca Postgres deliberadamente. Un fallo de liveness durante una caída de la base de datos podría reiniciar todas las réplicas sin reparar la dependencia. Añade una comprobación de readiness separada cuando el orquestador deba dejar de enviar tráfico a una instancia que no puede alcanzar Postgres. Las rutas de error registran el error capturado, pero devuelven un string constante. Devolver error.message en el body es la forma en que las cadenas de conexión acaban en el navegador de otra persona.


La parte con forma de Celery, sin Celery

Los trabajos largos no pertenecen a un handler de peticiones. La API inserta una fila y devuelve 202. Un worker reclama la fila.

Aquí no hay Redis ni BullMQ. PostgreSQL documenta SKIP LOCKED para múltiples consumidores de una tabla similar a una cola. La cláusula proporciona a este pequeño servicio una cola at-least-once en una sola tabla. Es transaccional con el resto de tus escrituras y supone un servicio menos en docker-compose.yml.

La query de reclamación de packages/agent-core/src/db/storage.ts es:

const [candidate] = await tx
    .select({ id: runs.id })
    .from(runs)
    // The real query also picks up rows whose lock went stale; trimmed here.
    .where(and(eq(runs.kind, kind), eq(runs.status, "queued")))
    .orderBy(runs.createdAt)
    .limit(1)
    // `.for()` exists but is undocumented; SKIP LOCKED rides in its second
    // argument.
    .for("update", { skipLocked: true });

La fila queda bloqueada durante la transacción, y cualquier worker concurrente que ejecute la misma query la omite en lugar de bloquearse. Por tanto, dos reclamaciones simultáneas no reciben la misma fila no obsoleta. Un test de integración realiza dos reclamaciones a la vez mediante Promise.all y comprueba que devuelven filas distintas. La versión ingenua, SELECT ... LIMIT 1 seguida de UPDATE, falla ese test: ambas transacciones leen la misma fila antes de que ninguna escriba, por lo que las dos inician el mismo trabajo.

Esto es ejecución at-least-once, no ejecución exactly-once. La query completa también vuelve a reclamar una fila running cuando su lock tiene más de cinco minutos, y el worker de demostración no renueva esa lease. Por tanto, un trabajo activo que dure más de cinco minutos puede reclamarse dos veces. Haz que los trabajos sean idempotentes. Para trabajos de larga duración, añade un heartbeat de la lease o establece el umbral de obsolescencia por encima del tiempo de ejecución máximo.

Añade BullMQ cuando necesites trabajos diferidos, schedules repetibles, prioridades, rate limits o un dashboard. En Python haría el mismo cambio de una tabla de base de datos a Celery. Antes de eso, Redis es un servicio más que ejecutar, monitorizar y explicar a quien esté de guardia.

El worker vuelve a validar lo que lee de jsonb:

// The row was validated on the way in, but it has been through a database.
// A stored row can outlive the schema version that accepted it.
// The job is a batch-size sweep. Zod's own `.parse` throws; the poll loop
// catches that and marks the run failed.
const input = SweepRequestSchema.parse(run.input);

La suite de tests también proporciona al worker una fila cuyo seqLen es un string. El worker marca la ejecución como fallida y sigue haciendo polling, en lugar de caerse y reintentar eternamente la misma fila envenenada.

El trabajo de CPU expone otra limitación de Node. Un callback síncrono se ejecuta en el hilo del event loop y no se puede interrumpir. Un loop for que procese aritmética durante dos segundos bloquea durante dos segundos todas las peticiones, timers y comprobaciones de liveness de ese proceso. Un loop ajustado dentro de un async def bloquea asyncio de la misma manera. Ambos runtimes requieren que descargues explícitamente el trabajo de CPU.

await setTimeout(0) de node:timers/promises (el prefijo node: significa biblioteca estándar, por lo que node:timers es para Node lo que os es para Python) es await asyncio.sleep(0). El sweep cede el control después de cada tamaño de lote para que el proceso del worker pueda atender timers y otros callbacks. Ceder el control no paraleliza el trabajo de CPU. Node worker_threads puede ejecutar JavaScript en paralelo. Para trabajo de CPU puro en Python bajo una build habitual de CPython con GIL, usa un process pool en lugar de un thread pool. Este servicio no utiliza ninguno de los dos. Mantén los cálculos numéricos pesados en Python, donde ya residen las bibliotecas de soporte, y sácalos del event loop de la API.


Una herramienta, tres consumidores

EstimateKvCacheInput tiene tres consumidores:

  • El loop escrito a mano lo convierte mediante z.toJSONSchema.
  • El AI SDK lo recibe sin cambios.
  • El servidor MCP publica su forma.

Por eso existe packages/schemas.

A partir de apps/mcp/src/index.ts:

server.registerTool(
    "estimate_kv_cache",
    {
        description: "Estimate KV-cache VRAM in GiB for a served model...",
        // A Zod object schema keeps the map of fields you passed in on
        // `.shape`. This SDK wants that map, not the schema wrapped around it.
        inputSchema: EstimateKvCacheInput.shape,
    },
    async ({ model, seqLen, batchSize }) => {
        /* ... */
    },
);

await server.connect(new StdioServerTransport());

Hay dos detalles importantes antes de conectar un cliente. Primero, un servidor iniciado de esta forma utiliza su propio stdin y stdout para comunicarse con el cliente. Cada línea es un mensaje JSON-RPC. Un console.log suelto, el equivalente en JavaScript de print, corrompe entonces un mensaje. El cliente se desconecta con un error de parseo que no indica ningún archivo. Envía todos los diagnósticos a stderr.

Segundo, un fallo de dominio debería devolver isError: true con un mensaje. El modelo que realiza la llamada puede corregirla, igual que después de recibir argumentos de herramienta no válidos en el agent loop.

Puedes controlar el servidor con printf y un pipe. Merece la pena hacerlo una vez antes de conectarlo a un cliente real:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"estimate_kv_cache","arguments":{"model":"llama-3.1-70b","seqLen":8192,"batchSize":4}}}' \
  | pnpm -s mcp   # -s suppresses pnpm's own output so only JSON-RPC comes back

La prueba coincide con la versión del protocolo utilizada por el README del repositorio complementario. Para un cliente real, utiliza el SDK en lugar de mantener mensajes JSON-RPC a mano.


Prueba un agente sin clave de API

Vitest cumple el papel de pytest, pero la estructura es diferente. describe agrupa tests relacionados. it y test definen cada uno un caso de test. test.each se parece a parametrize, beforeEach proporciona la configuración por test, vi.fn() crea una función mock y describe.skipIf omite condicionalmente un grupo.

Los tests del agente dependen de una decisión: runAgent recibe un cliente de OpenAI como parámetro en lugar de construirlo. El fake es un objeto con un método chat.completions.create que devuelve un iterable asíncrono guionizado:

function fakeClient(scripts: Chunk[][]): OpenAI {
    let call = 0;
    return {
        chat: {
            completions: {
                create: async () => {
                    const script = scripts[call++] ?? [];
                    // Defines an async generator and calls it on the same
                    // line, so `create` hands back something you can
                    // `for await` over, which is the shape a real streaming
                    // response has.
                    return (async function* () {
                        for (const chunk of script) yield chunk;
                    })();
                },
            },
        },
        // TypeScript refuses a direct cast between unrelated shapes, so you
        // launder it through `unknown` first. A lie to the compiler, confined
        // to one line in a test file, which is the only place it belongs.
    } as unknown as OpenAI;
}

Los tests dividen un string de argumentos JSON entre varios chunks y gestionan dos tool calls en una misma respuesta. También cubren batchSize no válido, JSON malformado, nombres de herramientas desconocidos y un modelo que sigue llamando a herramientas hasta que maxSteps lo detiene. El archivo de tests se ejecuta en mucho menos de un segundo, sin red ni clave.

Los tests de integración contra Postgres utilizan describe.skipIf(!process.env.DATABASE_URL), por lo que pnpm test funciona en un clon nuevo sin Postgres en ejecución, y CI los activa proporcionando la variable. El repositorio tiene 40 tests. Treinta y seis se ejecutan sin Postgres ni clave de API.


Registra eventos estructurados con Pino

Pino cumple el mismo papel que structlog: un objeto JSON por línea, loggers hijos con campos vinculados y redacción explícita. El repositorio complementario lo configura en packages/observability/src/logger.ts:

const log = pino({
    redact: {
        paths: [
            "req.headers.authorization",
            "apiKey",
            "OPENAI_API_KEY",
            "*.apiKey",
        ],
        censor: "[redacted]",
    },
});

Sin redacción, log.info({ req }, "...") puede copiar una cabecera Authorization al backend de logs.


Traza el trabajo de la aplicación con spans manuales

El repositorio complementario utiliza OpenTelemetry para tres spans a nivel de aplicación: agent.run, agent.tool y worker.sweep. No instala instrumentación automática para HTTP o Postgres. startTracing() en packages/observability/src/tracing.ts crea un NodeSDK con un exporter de trazas OTLP. Si falta OTEL_EXPORTER_OTLP_ENDPOINT, deja la trazabilidad desactivada.

El trabajo en sí se envuelve con withSpan() del mismo archivo:

return tracer.startActiveSpan(name, { attributes }, async (span) => {
    try {
        return await fn(span);
    } finally {
        span.end();
    }
});

JavaScript no tiene una sintaxis de context manager al estilo de Python. Aquí, el callback es el bloque que rodearía un context manager de Python. El helper completo también registra excepciones y establece el estado del span antes de relanzarlas.

Los spans automáticos de HTTP y base de datos son una funcionalidad independiente. Requieren los paquetes de instrumentación correspondientes y una inicialización anterior a la carga de los módulos instrumentados. Añádelos solo cuando esos spans sean útiles y sigue la configuración del OpenTelemetry Node SDK para consultar las versiones exactas de los paquetes que despliegues.


Despliega el monorepo en Docker

Usa el Dockerfile proporcionado. El contenedor inicia la API con el loader tsx. No necesitas elegir ni invocar un runner de TypeScript al desplegarlo.

El build utiliza pnpm fetch para que las descargas de dependencias permanezcan en caché hasta que cambie el lockfile. Después usa pnpm deploy para copiar la API y sus dependencias de producción a un directorio autocontenido. La fase de runtime se ejecuta con el usuario sin privilegios node, y su CMD en formato exec permite que la API reciba SIGTERM directamente para un apagado ordenado.

Por qué el Dockerfile carga tsx

Node 24 puede ejecutar un subconjunto limitado de TypeScript eliminando las anotaciones de tipos. No comprueba los tipos ni realiza las transformaciones que admite un runner completo de TypeScript. Los scripts del paquete del repositorio ocultan ese detalle. pnpm check realiza la comprobación estática por separado.

El contenedor expone otra limitación. pnpm deploy copia los paquetes del workspace bajo node_modules, y Node se niega deliberadamente a eliminar TypeScript allí (documentación de TypeScript de Node). La primera versión de la imagen fallaba con ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Funciona. Me desanimé.

El Dockerfile soluciona el problema cargando tsx, que procesa esos archivos .ts antes de que Node los ejecute. Un equipo que quiera incluir únicamente archivos .js en su imagen de runtime puede añadir un paso de compilación. Es un diseño de producción alternativo, no un paso adicional necesario para ejecutar este repositorio complementario.


Un itinerario de tres semanas

Los ingenieros de Python con experiencia pueden saltarse el material que enseña variables y bucles. Esta secuencia se centra en las partes que difieren de Python. La columna de construcción es el objetivo de cada fila. Las lecturas sirven de apoyo.

SemanaLeerConstruir
1javascript.info: solo módulos, promises y objetos. Conserva la guía de JS de MDN como referencia.Reescribe una CLI de Python en TypeScript. Añade un script package.json. Ejecuta el script y pnpm typecheck.
1-2Lee la referencia de tsconfig de TypeScript, los tutoriales gratuitos de Total TypeScript y la documentación de Zod.Crea un módulo de configuración validado con Zod y una tagged union. Tras comprobar el tag, el compilador sabe qué variante contiene el bloque.
2Lee la documentación de Hono, Drizzle, Vitest y Biome.Crea un proxy con streaming a un endpoint compatible con OpenAI y un log respaldado por Drizzle.
3Lee la documentación del AI SDK y del MCP TypeScript SDK.Crea un agente con tool calling. Después crea un servidor MCP que exponga una de sus herramientas.

Empieza por los tutoriales gratuitos de Total TypeScript. Paga por el material avanzado solo cuando trabajes con generics y conditional types propios de bibliotecas. Sáltate todos los cursos de «introducción a JavaScript» y cualquier contenido centrado en React, salvo que el producto lo requiera.

Para consultar convenciones de producción, goldbergyoni/nodebestpractices ofrece una checklist amplia mantenida por la comunidad. Verifica en la documentación actual de Node cualquier recomendación que afecte al comportamiento del runtime o a la seguridad.


Compromisos

Mantén los cálculos numéricos en Python

Node funciona bien para la orquestación, el servicio HTTP y el streaming. Los cálculos sostenidos limitados por CPU bloquean su hilo principal del event loop. Mantén vLLM y el código de entrenamiento en Python, salvo que un workload medido justifique moverlos.

Valida cada frontera en runtime

Una anotación de TypeScript no comprueba un body HTTP, una variable de entorno, los argumentos de una herramienta generados por el modelo ni una fila leída de jsonb. Cada frontera necesita un esquema en runtime.

Aísla la inestabilidad del SDK

AI SDK 6 sustituyó Experimental_Agent por ToolLoopAgent y cambió el nombre de la configuración del agente de system a instructions (guía de migración de AI SDK 6). El repositorio complementario llama directamente a streamText en AI SDK 7 y expone su propio stream AgentEvent. Esa frontera mantiene la ruta HTTP sin cambios cuando cambia el código del SDK.

Omite el loop escrito a mano cuando el plazo sea más importante

Escribir el loop una vez te enseña qué comportamiento gestiona el SDK. Si necesitas desplegar primero y no tienes motivos para personalizar los fallos de validación, empieza con el SDK.


Ideas clave

  1. Instala Node 24 y pnpm. Después usa los scripts del repositorio: pnpm demo, pnpm dev:api, pnpm dev:worker y pnpm check. Los scripts ocultan los comandos de nivel inferior del runtime y del comprobador de tipos.
  2. La validación en runtime es estructuralmente necesaria. Zod es el validador elegido por este proyecto. Los tipos estáticos no inspeccionan bodies HTTP, variables de entorno, salida del modelo ni filas de la base de datos. Con Zod, declara un esquema en runtime y deriva el tipo de TypeScript mediante z.infer.
  3. El stack se relaciona en su mayor parte limpiamente: pnpm para uv, Hono para FastAPI, Drizzle para SQLAlchemy, Vitest para pytest y Biome para Ruff. Tres filas no son reemplazos directos: validación, comprobación de tipos y cola de trabajos.
  4. Escribe un agent loop a mano si necesitas aprender o personalizar las rutas ocultas: acumulación de fragmentos, validación y feedback de errores de herramientas.
  5. Convierte el loop en un generador asíncrono. La ruta HTTP consume sus valores AgentEvent, emite frames SSE, acumula el texto y lo persiste después del stream. Los tests consumen el generador por separado, sin red ni clave de API.
  6. Postgres puede proporcionar una cola at-least-once. Haz que los handlers sean idempotentes y renueva la lease o dimensiona su duración por encima del tiempo máximo de ejecución para trabajos largos. Añade BullMQ cuando necesites retrasos, prioridades o schedules.
  7. Usa el Dockerfile proporcionado en producción. Empaqueta la aplicación seleccionada, carga TypeScript con tsx, se ejecuta con un usuario sin privilegios y reenvía las señales de apagado al proceso de la API.

Referencias

Repositorio de demostración

  • slavadubrov/typescript-agent-service: el monorepo utilizado en todo el artículo: API de Hono con SSE, dos implementaciones de agent loop, almacenamiento con Drizzle, worker, servidor MCP y 40 tests

Runtime y lenguaje

Herramientas

  • pnpm y instalación de pnpm: package manager, workspaces y configuración
  • Biome: lint, formateo y ordenación de imports en un único binario
  • Vitest: runner de tests que no necesita configuración de transformación
  • Total TypeScript: tutoriales gratuitos y un itinerario de pago para tipos avanzados

Bibliotecas

  • Zod: validación de esquemas e inferencia de tipos. La versión 4 incluye z.toJSONSchema.
  • Hono: framework HTTP estándar de la Web
  • Drizzle ORM: ORM de TypeScript orientado a SQL con migraciones drizzle-kit
  • Documentación de SELECT de PostgreSQL: la cláusula de locking FOR UPDATE ... SKIP LOCKED
  • BullMQ: cola respaldada por Redis para cuando la tabla de la base de datos no sea suficiente

AI y agentes

Convenciones