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

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

Esta es una guía rápida de incorporación para ingenieros de Python con experiencia que necesitan poner en producción servicios de AI en TypeScript y Node. Está dirigida a 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 por la programación básica o por el trabajo con el DOM del frontend. Este artículo parte de conceptos de servicios de Python. Al terminar, podrás relacionar una pila de servicios de Python con su equivalente en TypeScript y reconocer los hábitos de Python que provocan errores en JavaScript. El ejemplo sigue un servicio de agentes con streaming, desde el esquema hasta el despliegue.

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

El servicio utiliza 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. Utiliza 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 de agentes HTTP/SSE offline y el cálculo de barrido del worker sin necesidad de una API key.

Aquí cubro únicamente el trabajo de backend y 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 guionizado. 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 API key, base de datos ni Docker. pnpm check ejecuta el comprobador de tipos, el linter, la comprobación del 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 utilizo en el resto del artículo. El repositorio oculta los comandos de Node y TypeScript de bajo nivel tras scripts pnpm con nombre, del mismo modo que un proyecto de Python podría ocultar los comandos uv run tras objetivos de make. No mezcles npm install en este repositorio de pnpm. Utiliza pnpm install para que pnpm-lock.yaml siga siendo el único lockfile.


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

Los nombres parecidos esconden funciones distintas:

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


La pila, relacionada con Python

Dos columnas relacionan cada responsabilidad de un servicio de AI en Python con su sustituto en TypeScript. Las filas cubren metadatos del proyecto, paquetes, validación, HTTP, SQL, colas, tests, linting y comprobaciones de tipos. Tres filas no son sustituciones directas.

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

ResponsabilidadPythonTypeScriptPor qué no es una sustitución directa
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 utiliza las APIs Request y Response, estándar de la Web y ofrece adaptadores para Node y runtimes serverless. Esa portabilidad resulta útil para esta pequeña API con streaming, por eso 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 sin procesar cuando el query builder no puede expresar limpiamente una cláusula de Postgres. Elegiría Prisma cuando su cliente generado y las herramientas que lo rodean encajaran 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 cuando el proyecto dependa de reglas personalizadas que Biome no proporcione.

Vitest para los tests

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


Lee la sintaxis de TypeScript utilizada más adelante

Ten esta tabla al lado de 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 async
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 sea importable
switch / casematch, salvo que los casos continúan si no terminan en break o return
for awaititerar sobre un generador async
i++incrementa y devuelve el valor anterior
/^https?$/un literal de regex, sin necesidad de re.compile
T[], Map<K, V>list[T], dict[K, V]

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

Una breve traducción de enum

Para estados cuyo valor es una cadena, este repositorio utiliza un objeto y un tipo unión de cadenas inferido:

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

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

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

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


Las siete diferencias semánticas que hacen perder tiempo

La tabla de sintaxis 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

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

2. null y undefined son distintos

null suele indicar una ausencia deliberada. 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 al escribir un valor predeterminado. || sustituye el lado izquierdo siempre que sea falsy. Eso incluye 0, "" y false. ?? solo sustituye null y undefined. Por tanto, 0 || 10 es 10, mientras que 0 ?? 10 es 0. Así es como un tamaño de batch igual a 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 una cadena, 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 con await 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 se encarga de la planificación del runtime. Mantiene vivo el proceso mientras existan handles activos o peticiones, como timers y sockets. Una Promise pendiente por sí sola no mantiene vivo Node. No envuelvas el programa en asyncio.run. En un módulo ES puedes utilizar await en el nivel superior cuando el arranque deba esperar una operación async.

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 float de Python. El estándar técnico de este formato se llama 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 cadenas en los límites del servicio. Convertir un bigint de Postgres en un number de JavaScript puede redondearlo. Para enteros exactos más grandes, JavaScript proporciona el tipo independiente BigInt, que no se puede mezclar con los números ordinarios.

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

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

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

Un objeto no es una tabla de clave-valor limpia 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 una cadena externa selecciona un campo de un objeto, llama a Object.hasOwn antes de leerlo. Si necesitas un diccionario de propósito general, utiliza Map. Map se parece más a dict de Python: una clave existe solo 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 fuente JavaScript que comparte código con otros archivos se llama módulo. Este proyecto utiliza el formato de módulos moderno, módulos ES, 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 la 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 de runtime. z.coerce.number() gestiona el hecho de que cada valor definido en process.env (el os.environ de Node) sea una cadena. Cumple el mismo papel que la coerción de configuración numérica de pydantic, aunque las cadenas exactas que acepta cada uno difieren. Esto sigue el patrón pydantic-settings y se ejecuta una vez durante el arranque. Un entorno no válido produce entonces un error legible de arranque, 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 la cadena. Después el valor llega 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, así que este proyecto no necesita la dependencia zod-to-json-schema habitual en tutoriales antiguos. Esto importa cuando un esquema debe alimentar a tres consumidores, que es el tema de la sección «Una herramienta, tres consumidores».


El servicio

Un workspace de pnpm contiene packages y apps. Los packages contienen esquemas, código de agentes y observabilidad. Un esquema alimenta el loop escrito a mano, el Vercel AI SDK y el servidor MCP. Las apps contienen una API Hono, un worker y un servidor MCP. 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 el consumo de KV cache: la memoria de la GPU utilizada para mantener las claves y valores de atención de las peticiones en curso. Ambas herramientas hacen deliberadamente una aritmética sencilla. No necesitan red y siempre producen la misma respuesta. Esto permite probar el servicio sin una API key. El estimador de KV cache también se publica mediante el Model Context Protocol (MCP), de modo 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. Esa frontera del paquete no depende del comando que inicie la aplicación.

Este workspace privado apunta esas entradas al código fuente .ts porque todos los consumidores forman parte del mismo repositorio. Los paquetes públicos de npm normalmente publican JavaScript junto con declaraciones de tipos .d.ts para que los consumidores Node ordinarios 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 paso de un agent loop. El modelo transmite chunks. El loop ensambla los tool calls por índice, analiza su JSON y los valida con Zod. Las llamadas válidas ejecutan la herramienta. Las llamadas no válidas producen un error que el modelo lee antes de repetir el loop. El loop genera valores AgentEvent tipados. La ruta HTTP emite un frame SSE por evento, acumula el texto y llama al almacenamiento después del stream. Los tests consumen el generador por separado.

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

  1. Llamar al modelo con las definiciones de las herramientas.
  2. Validar y ejecutar las herramientas solicitadas.
  3. Añadir los resultados a los mensajes.
  4. Volver a llamar al modelo.

Escribe este loop una vez. Así, el comportamiento del framework se convierte en una decisión de ingeniería que puedes justificar.

El loop es un async function*, un generador async, exactamente la forma del async def de Python con yield. La ruta HTTP itera sobre 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 recogen sus eventos en un array. Un frame SSE es un chunk de una respuesta HTTP de larga duración. La sección siguiente explica el formato.

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 text del loop contiene un paso del modelo. El loop la utiliza en el mensaje del assistant para el paso siguiente o en el evento done final. No es el acumulador de la ruta, que se persiste más adelante.

El map partial es la parte que ocultan los frameworks. El SDK expone fragmentos de cadena de los argumentos de la función, que pueden dividir el JSON serializado en posiciones arbitrarias. Varias llamadas en paralelo también pueden entrelazarse. El repositorio incluye un test que divide {"model":"llama-3.1-8b",...} en cuatro chunks.

La segunda cosa 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 estructura. Cada fallo se convierte en un mensaje que el modelo lee. Durante la ejecución, un ToolError esperado también se convierte en un resultado de la herramienta para que el modelo pueda corregir su 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 con el que el modelo puede actuar, en lugar de en un stack trace.

strict: true en una definición de función de OpenAI pide al proveedor que restrinja el decoding al esquema. No tiene relación con el flag strict de 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 el flag. Los argumentos también deben sobrevivir a JSON.parse.

El loop llama a /chat/completions porque el repositorio complementario está dirigido a servidores compatibles con OpenAI. vLLM, SGLang y Ollama documentan ese endpoint, por lo que OPENAI_BASE_URL puede apuntar el mismo cliente a cualquiera de ellos. La cobertura de la 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 lo que sacrificas

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

El repositorio complementario fija AI SDK 7.0.42 en packages/agent-core/package.json. Su 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 partes del código de la aplicación:

stopWhen acepta varias condiciones, incluido un límite de pasos o un tool call concreto. 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 una llamada rechazada. En la versión del SDK configuras ese comportamiento mediante repairToolCall. La contrapartida funciona también en la otra dirección. En la versión del SDK, el 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 sin abstracciones, el manejo específico de las peticiones y del stream del proveedor es código que debes cambiar tú.

Yo escribo el loop a mano en el primer proyecto y utilizo el SDK en los siguientes. Pagas esa lección 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 leen como 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 del handler. El c del handler siguiente es el contexto de petición de Hono, el objeto que FastAPI divide 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é ventaja proporciona.

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 },
        });
    });
});

zValidator valida el body y proporciona a c.req.valid("json") el tipo que produce el esquema. Si lo omites, el body se tipa como any, el mecanismo de TypeScript para desactivar la comprobación, en el que cualquier acceso a una propiedad compila y no se comprueba nada. Eso desactiva la ventaja de seguridad de tipos del esquema.

Esta ruta utiliza Server-Sent Events en lugar de WebSockets. El servidor mantiene abierta una respuesta HTTP mientras escribe frames event: <name> y data: <json> y la cierra después del evento final. El tráfico fluye del servidor al cliente, que es lo que necesita este stream de agentes. 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 gestió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 retorna. Ese return es importante: solo un stream completado correctamente llega a storage.createRun.

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 del servidor. Cualquier cliente que solo compruebe el código de estado informará de éxito en una ejecución fallida.

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


La parte con forma de Celery, sin Celery

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

Aquí no hay Redis ni BullMQ. PostgreSQL documenta SKIP LOCKED para varios consumidores de una tabla similar a una cola. La cláusula proporciona a este pequeño servicio una cola at-least-once en una única 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 que no esté obsoleta. Un test de integración lanza 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 exactly-once. La query completa también reclama de nuevo una fila running cuando su lock tiene más de cinco minutos, y el worker de demostración no renueva ese 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 largos, añade un heartbeat del lease o establece el umbral de obsolescencia por encima del tiempo máximo de ejecución.

Añade BullMQ cuando necesites trabajos diferidos, planificaciones repetibles, prioridades, rate limits o un dashboard. Haría el mismo cambio de una tabla de base de datos a Celery en Python. 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 una cadena. El worker marca la ejecución como fallida y sigue haciendo polling, en lugar de bloquearse y reintentar la misma fila tóxica indefinidamente.

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

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


Una herramienta, tres consumidores

EstimateKvCacheInput tiene tres consumidores:

Esa reutilización es el motivo de que exista 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());

Antes de conectar un cliente importan dos detalles. 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 JavaScript de print, corrompe el 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 llama puede corregir entonces la llamada, igual que después de unos 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 apuntar a él 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 utiliza la versión del protocolo del README del repositorio complementario. Para un cliente real, utiliza el SDK en lugar de mantener mensajes JSON-RPC a mano.


Probar un agente sin API key

Vitest ocupa 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 setup 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 OpenAI como parámetro en lugar de construirlo. El fake es un objeto con un método chat.completions.create que devuelve un iterable async 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 una cadena de argumentos JSON en varios chunks y gestionan dos tool calls en una misma respuesta. También cubren batchSize no válido, JSON mal formado, nombres de herramientas desconocidos y un modelo que sigue llamando a herramientas hasta que maxSteps lo detiene. El archivo de tests se ejecuta en bastante menos de un segundo, sin red ni clave.

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


Registrar eventos estructurados con Pino

Pino cumple el mismo papel que structlog: un objeto JSON por línea, loggers hijo 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.


Trazar el trabajo de la aplicación con spans manuales

El repositorio complementario utiliza OpenTelemetry para tres spans de 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 exportador de trazas OTLP. Si OTEL_EXPORTER_OTLP_ENDPOINT está ausente, deja el tracing desactivado.

El trabajo 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 las excepciones y establece el estado del span antes de volver a lanzarlas.

Los spans automáticos de HTTP y base de datos son una función 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 Node SDK de OpenTelemetry para consultar las versiones exactas de los paquetes que despliegas.


Distribuir el monorepo con Docker

Utiliza 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 utiliza pnpm deploy para copiar la API y sus dependencias de producción a un directorio autocontenido. La fase de runtime se ejecuta como el usuario no root 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 de paquete del repositorio ocultan ese detalle. pnpm check realiza la comprobación estática independiente.

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

El Dockerfile resuelve el problema cargando tsx, que gestiona esos archivos .ts antes de que Node los ejecute. Un equipo que quiera ú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 plan 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 build es el objetivo de cada fila. La lectura sirve de apoyo.

SemanaLeerCrear
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. Después de 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 hacia 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 propios de bibliotecas y tipos condicionales. Sáltate cualquier curso de «introducción a JavaScript» y todo lo orientado a React salvo que el producto lo requiera.

Para consultar una visión general de las 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 una carga medida justifique trasladarlos.

Valida cada límite 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 límite necesita un esquema de runtime.

Aísla el churn 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. Ese límite mantiene sin cambios la ruta HTTP cuando cambia el código del SDK.

Omite el loop escrito a mano cuando el plazo importe más

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


Conclusiones clave

  1. Instala Node 24 y pnpm. Después utiliza los scripts del repositorio: pnpm demo, pnpm dev:api, pnpm dev:worker y pnpm check. Los scripts ocultan los comandos de runtime y comprobación de tipos de bajo nivel.
  2. La validación en runtime es estructuralmente necesaria. Zod es el validador elegido en este proyecto. Los tipos estáticos no inspeccionan bodies HTTP, variables de entorno, salidas del modelo ni filas de la base de datos. Con Zod, declara un esquema de runtime y deriva el tipo de TypeScript mediante z.infer.
  3. La pila se relaciona en gran medida de forma limpia: pnpm para uv, Hono para FastAPI, Drizzle para SQLAlchemy, Vitest para pytest y Biome para Ruff. Tres filas no son sustituciones: 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 async. 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 API key.
  6. Postgres puede proporcionar una cola at-least-once. Haz que los handlers sean idempotentes y renueva el 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 planificaciones.
  7. Utiliza el Dockerfile proporcionado para producción. Empaqueta la app seleccionada, carga TypeScript con tsx, se ejecuta como usuario no root y reenvía las señales de apagado al proceso de la API.

Referencias

Repositorio de demostración

Runtime y lenguaje

Herramientas

Bibliotecas

AI y agentes

Convenciones