TypeScript voor Python ML engineers: bouw een agent service

Automatische vertaling Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.

Dit is een snelle onboarding guide voor ervaren Python-engineers die AI-services in TypeScript en Node moeten opleveren. De guide is geschreven voor ML-engineers, data scientists en backend developers die geen beginnerscursus JavaScript nodig hebben.

Ik heb deze onboarding de afgelopen maanden zelf doorlopen, nadat ik in Python en Java had gewerkt. De meeste guides die ik vond, begonnen met basic programming of front-end DOM-werk. Dit artikel vertrekt vanuit concepten uit Python-services. Aan het einde kun je een Python-service-stack mappen naar TypeScript en Python-gewoonten herkennen die JavaScript-bugs veroorzaken. Het doorlopende voorbeeld volgt een streaming agent service van schema tot deployment.

Samenvatting: Installeer Node 24 en pnpm. Gebruik daarna de pnpm-commando’s van de repository. Voer pnpm demo uit voor het offline voorbeeld, pnpm dev:api en pnpm dev:worker voor development, en pnpm check vóór een commit. Je hoeft node, tsx of de TypeScript-checker niet zelf uit te voeren. De package scripts doen dat.

De service gebruikt Zod, Hono, Drizzle, Vitest en Biome. Ze dekken grotendeels hetzelfde terrein als pydantic, FastAPI, SQLAlchemy, pytest en Ruff. Houd zware numerieke berekeningen in Python. Gebruik deze TypeScript-service voor orchestration, HTTP en streaming.

Alles wat hier wordt beschreven staat als bestand in slavadubrov/typescript-agent-service, de companion repository die bij dit artikel is gepubliceerd. De repository bevat een HTTP API, twee versies van dezelfde agent loop, Postgres run history, een worker en een MCP server. pnpm install && pnpm demo voert het offline HTTP/SSE-agentpad en de sweep-berekening van de worker uit zonder API-key.

Ik behandel alleen backend- en AI-werk. Geen React. Er is ook geen browser bundler.


Start eerst met de companion

Installeer Node 24 en pnpm volgens de officiële pnpm-installatie-instructies. Clone daarna de companion repository en voer het volgende uit:

pnpm install
pnpm demo
pnpm check

pnpm demo test de HTTP-handler, agent loop en SSE-stream met een scripted model. Het roept ook de runSweep-berekening van de worker aan. Het start het workerproces of de databasequeue niet. De demo heeft geen API-key, database of Docker nodig. pnpm check voert de type checker, linter, formatter check en tests uit.

Om de echte API en worker te starten:

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

Dit zijn de commando’s die ik in de rest van dit artikel gebruik. De repository verbergt de low-level Node- en TypeScript-commando’s achter benoemde pnpm-scripts, vergelijkbaar met hoe een Python-project uv run-commando’s achter make-targets kan verbergen. Meng npm install niet in deze pnpm-repository. Gebruik pnpm install, zodat pnpm-lock.yaml het enige lockfile blijft.


Wat Node, npm, pnpm, TypeScript en tsx doen

De vergelijkbare namen verbergen afzonderlijke taken:

  • JavaScript is de taal.
  • Node.js is de runtime, grofweg de JavaScript-variant van CPython. Installeer Node 24 voor dit project.
  • Het npm registry is de package-index, grofweg het equivalent van PyPI. De opdracht npm wordt met Node meegeleverd en kan packages uit die registry installeren.
  • pnpm is de package manager die deze repository gebruikt. Deze installeert packages uit het npm registry, beheert de monorepo-workspace en voert de opdrachten uit die in package.json zijn gedeclareerd.
  • package.json is het projectmanifest, het dichtst bij pyproject.toml. De sectie scripts bevat namen zoals demo, check en dev:api voor langere opdrachten.
  • TypeScript is JavaScript met statische types. De opdracht tsc controleert die types. De repository voert deze uit via pnpm check of pnpm typecheck.
  • tsx voert .ts-bestanden uit zonder afzonderlijke build. De developmentscripts gebruiken de modus watch om de API of worker na een wijziging in de source opnieuw te starten. Je voert deze in deze guide niet rechtstreeks uit.

Voer voor deze repository de scripts van pnpm uit. Node is de runtime binnen die scripts en het meegeleverde Dockerfile handelt production af.


De stack, vertaald vanuit Python

Twee kolommen brengen elk onderdeel van een Python-AI-service in kaart met de TypeScript-vervanger. Zes vervangingen staan op dezelfde positie; een apart paneel markeert de semantische verschillen in validation, queues en type checking.Twee kolommen brengen elk onderdeel van een Python-AI-service in kaart met de TypeScript-vervanger. Zes vervangingen staan op dezelfde positie; een apart paneel markeert de semantische verschillen in validation, queues en type checking.

Het grootste deel van de mapping is rechttoe rechtaan, en dat is goed nieuws. Drie rijen zijn dat niet:

OnderdeelPythonTypeScriptWaarom dit geen één-op-één-vervanging is
ValidationpydanticzodHet schema is de bron. Het type wordt daaruit gegenereerd, niet andersom
Type checkmypyTypeScript (pnpm typecheck)Beide controleren source zonder data te valideren die tijdens runtime binnenkomt
Job queuecelery + Redisbullmq (Redis-backed queue) of SQLPostgres kan een at-least-once queue implementeren. Mogelijk heb je geen broker nodig

De companion gebruikt vier libraries die uitleg verdienen.

Hono voor de HTTP-laag

Express en Fastify zijn Node-gerichte alternatieven. Hono gebruikt de Web-standaard Request- en Response-API’s en biedt adapters voor Node en serverless runtimes. Die portability is nuttig voor deze kleine streaming API, dus koos ik Hono.

Drizzle voor SQL

Drizzle houdt het schema in TypeScript en vereist geen stap voor het genereren van een client. Raw SQL blijft bovendien beschikbaar wanneer de query builder een Postgres-clause niet netjes kan uitdrukken. Ik zou Prisma kiezen wanneer de gegenereerde client en de omliggende tooling beter bij het team passen.

Biome voor linting en formatting

Biome verzorgt linting, formatting en het sorteren van imports met één binary en één configuratiebestand. Gebruik ESLint als het project afhankelijk is van custom rules die Biome niet biedt.

Vitest voor tests

Vitest voert de .ts-tests van de companion uit zonder afzonderlijke transform-configuratie.


Lees de hieronder gebruikte TypeScript-syntax

Houd deze tabel bij de servicevoorbeelden als naslagwerk.

TypeScriptPython / toelichting
(x) => expressionanonieme functie met een expression body, vergelijkbaar met lambda x: expression
(x) => { statements }anonieme functie met een statement body
async (x) => { statements }async anonieme functie
const { model, seqLen } = requesthaal de eigenschappen model en seqLen uit request
const [first] = xsfirst = xs[0]. Levert bij een lege waarde undefined op, niet IndexError
{ type: "error", message }{"type": "error", "message": message}. Een losse naam wordt dat veld
text ${x}f-string
cond ? a : ba if cond else b
const / letBeide binden een naam. const verbiedt rebinding, terwijl let dit toestaat
exportmaakt een naam importeerbaar
switch / casematch, maar cases vallen door tenzij ze eindigen in break of return
for awaititereren over een async generator
i++verhoogt de waarde en retourneert de oude waarde
/^https?$/een regex literal, geen re.compile nodig
T[], Map<K, V>list[T], dict[K, V]

Gebruik const tenzij de binding moet veranderen. Gebruik let voor een counter, accumulator of een andere binding die je opnieuw toewijst.

Een korte enum-vertaling

Voor states met stringwaarden gebruikt deze repository een object plus een afgeleid string-union type:

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

Het object stelt Status.Queued beschikbaar terwijl het programma draait. De regel type staat tijdens typechecking alleen "queued" of "running" toe. Samen vervullen ze de twee rollen van deze Python-declaratie:

from enum import Enum

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

Je hoeft het patroon alleen te herkennen. as const zorgt ervoor dat de waarden van het object exact de opgegeven strings blijven, in plaats van ze te verbreden naar een willekeurig string.


De zeven semantische verschillen die tijd kosten

De syntaxis­tabel helpt je door de voorbeelden heen. Deze semantische verschillen zijn de plekken waar Python-gewoonten bugs veroorzaken.

1. Lege arrays en objects zijn truthy

De Python-gewoonte dat een “lege container falsy is” draagt het slechtst over. if (results) is true voor een lege array. Schrijf if (results.length).

2. null en undefined zijn verschillend

null markeert doorgaans een bewuste afwezigheid. undefined betekent meestal dat een waarde ontbreekt of niet is toegewezen, hoewel code deze expliciet kan toewijzen. Library- code retourneert voortdurend undefined. Het verschil wordt problematisch wanneer je een default schrijft. || vervangt de rechterkant zodra de linkerkant falsy is. Dat omvat 0, "" en false. ?? vervangt alleen null en undefined. Daarom is 0 || 10 10, terwijl 0 ?? 10 0 is. Dat verschil zorgt ervoor dat een batchgrootte van nul stilzwijgend tien wordt.

3. Een catch-block ontvangt unknown

Er is geen except ValueError:. Eén catch-block vangt alles op. Omdat JavaScript toestaat dat je een string, een getal of null gooit, typeert TypeScript de opgevangen waarde als unknown, het type “kan letterlijk alles zijn” onder strict. De bijbehorende optie schakelt strict in, en nieuwe projecten zouden die doorgaans moeten gebruiken. Om de fout te inspecteren, moet je de waarde eerst nader bepalen:

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. Promises starten onmiddellijk

Een async-functie aanroepen start de uitvoering van de body en retourneert een promise. Een Python-coroutine-object doet niets totdat je het await of inplant. Promise.all komt in de buurt van asyncio.gather. Promise.allSettled komt in de buurt van gather(..., return_exceptions=True), behalve dat elk resultaat wordt verpakt als { status, value } of { status, reason }.

Node beheert de runtime-scheduling. Het houdt het proces actief zolang er actieve handles of requests bestaan, zoals timers en sockets. Je wikkelt het programma niet in asyncio.run. In een ES-module kun je await op topniveau gebruiken wanneer de startup moet wachten op een async-bewerking.

5. JavaScript heeft één gewoon numeriek type

Het type number van JavaScript slaat waarden op als 64-bits floating-pointgetallen, ongeveer hetzelfde als Python’s float. De technische standaard voor dit formaat heet IEEE 754. Decimale waarden zijn benaderingen, dus 0.1 + 0.2 is niet exact 0.3, en gehele getallen blijven alleen exact tot en met 2**53 - 1, oftewel 9,007,199,254,740,991.

Houd 64-bits-ID’s als strings aan service boundaries. Een Postgres-bigint converteren naar een JavaScript-number kan afrondingsfouten veroorzaken. Voor grotere exacte gehele getallen biedt JavaScript het afzonderlijke type BigInt, dat niet kan worden gecombineerd met gewone getallen.

6. Gebruik Map wanneer je een Python-achtige dictionary nodig hebt

In JavaScript maakt {} een object aan. Objecten representeren meestal records met benoemde velden:

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

Een object is geen overzichtelijke key-value table zoals een Python-dict. Het erft enkele namen van JavaScript zelf. Dat kan een verrassend resultaat opleveren:

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

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

Als een externe string een veld uit een object selecteert, roep dan Object.hasOwn aan voordat je het veld uitleest. Gebruik Map als je een general-purpose dictionary nodig hebt. Map lijkt meer op Python’s dict: een key bestaat alleen wanneer je code die toevoegt.

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

7. Neem de extensie op in relative imports

Een JavaScript-sourcebestand dat code deelt met andere bestanden heet een module. Dit project gebruikt het moderne moduleformat ES modules, meestal afgekort tot ESM. ES staat voor ECMAScript, de formele naam van de JavaScript-taal. In de praktijk is ESM de import- en export-syntax die overal in dit project wordt gebruikt.

Voor een relative import vereist Node de exacte bestandsnaam. Het raadt niet of ./env staat voor ./env.ts of ./env.js:

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

Imports uit geïnstalleerde of workspace-packages gebruiken nog steeds de package name, zonder bestandsextensie:

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

Zod is pydantic met de pijl de andere kant op

In pydantic declareer je een class en krijg je een validator. In Zod declareer je een validator en leid je daaruit het type af. Dezelfde single source of truth, maar in de tegenovergestelde richting.

Uit 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> leest een static type uit het runtime-schema. z.coerce.number() verwerkt het feit dat elke gedefinieerde waarde in process.env (Node’s os.environ) een string is. Het vervult dezelfde rol als de numeric settings coercion van pydantic, hoewel de exacte strings die elk accepteert verschillen. Dit volgt het pydantic-settings-pattern en wordt één keer uitgevoerd bij startup. Een ongeldige environment veroorzaakt dan een leesbare startup-fout in plaats van een TypeError binnen een handler.

Een kale z.url() accepteert localhost:8000. De URL-standaard behandelt alles vóór de eerste dubbele punt als het schema. Daarom leest de standaard localhost: als een protocol met de naam “localhost” en accepteert de string. De waarde bereikt vervolgens de HTTP-client en faalt daar met minder context. Schema-validatie laat fouten eerder optreden, maar dwingt een permissief schema af als je dat zo hebt geschreven.

Zod 4 levert ook z.toJSONSchema, dus dit project heeft de zod-to-json-schema-dependency niet nodig die in oudere tutorials vaak voorkomt. Dat is relevant zodra één schema drie consumers moet bedienen, wat de onderstaande sectie “Eén tool, drie consumers” behandelt.


De service

Eén Zod-schema voedt een handgeschreven loop, de Vercel AI SDK en een MCP-publisher. De twee loop-adapters bedienen de Hono-API, de publisher bedient het MCP-proces en de API en worker delen een Postgres-tabel met runs.Eén Zod-schema voedt een handgeschreven loop, de Vercel AI SDK en een MCP-publisher. De twee loop-adapters bedienen de Hono-API, de publisher bedient het MCP-proces en de API en worker delen een Postgres-tabel met runs.

De demo-service dimensioneert LLM-deployments. Eén tool zoekt de architectuurconstanten van een model op. De andere schat de omvang van de KV-cache: het GPU-geheugen dat wordt gebruikt om attention keys en values voor actieve requests vast te houden. Beide tools voeren bewust eenvoudige rekenkundige bewerkingen uit. Ze hebben geen netwerk nodig en geven iedere keer hetzelfde antwoord. Daardoor kun je de service testen zonder API-key. De KV-cache-estimator wordt ook gepubliceerd via het Model Context Protocol (MCP), zodat andere AI-clients deze kunnen aanroepen.

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 is het bestand waarin de workspace wordt gedeclareerd. Interne packages krijgen een scoped name zoals @agent/core, waarbij het @agent/-prefix een naamgevingsconventie is, geen language feature. Elk package declareert zijn publieke entry point in package.json. Die package boundary hangt niet af van welk command de applicatie start.

Deze private workspace laat die entry points naar .ts-source wijzen, omdat elke consumer deel uitmaakt van dezelfde repository. Publieke npm-packages publiceren normaal JavaScript plus .d.ts-type declarations, zodat gewone Node-consumers de TypeScript-runner of build setup van de package-auteur niet nodig hebben.


Schrijf de tool-loop één keer met de hand

Een handgeschreven agent-loop assembleert gestreamde tool calls, valideert ze, voert geldige tools uit en voegt ongeldige input als tool error toe aan de message history. De getypeerde AgentEvent-stream voedt de HTTP-route en tests; de route slaat het eindresultaat op na het streamen.Een handgeschreven agent-loop assembleert gestreamde tool calls, valideert ze, voert geldige tools uit en voegt ongeldige input als tool error toe aan de message history. De getypeerde AgentEvent-stream voedt de HTTP-route en tests; de route slaat het eindresultaat op na het streamen.

Tool-calling agent-frameworks verpakken dezelfde basisloop:

  1. Roep het model aan met tool definitions.
  2. Valideer de aangevraagde tools en voer ze uit.
  3. Voeg de resultaten toe aan de messages.
  4. Roep het model opnieuw aan.

Schrijf deze loop één keer. Het gedrag van het framework wordt dan een engineeringkeuze die je kunt onderbouwen.

De loop is een async function*, een async generator, met exact dezelfde vorm als Python’s async def met yield. De HTTP-route itereert over die generator, zet elk event om in een Server-Sent Events-frame en verzamelt de tekst. Nadat de stream is beëindigd, roept de route storage.createRun één keer aan met de uiteindelijke tekst. Tests roepen de loop afzonderlijk aan en verzamelen de events in een array. Een SSE-frame is één chunk van een langlevende HTTP-response. De onderstaande sectie legt het formaat uit.

De loop heeft twee normale exits. Een response zonder tool calls levert done op, met stopReason: "stop". Als het model via maxSteps tools blijft aanvragen, wordt de generator nog steeds voltooid en levert deze done op, met stopReason: "max_steps". Behandel dat resultaat als voltooid maar afgekapt: de verzamelde tekst kan onvolledig zijn.

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

De loop-lokale variabele text bevat één modelstap. De loop gebruikt deze in het assistant message voor de volgende stap of in het uiteindelijke done-event. Het is niet de accumulator op routeniveau die later wordt gepersist.

De map partial is het deel dat frameworks verbergen. De SDK expose’t stringfragmenten van de functieargumenten, en die kunnen de geserialiseerde JSON op willekeurige posities opsplitsen. Meerdere parallelle calls kunnen ook door elkaar lopen. De repo bevat een test die {"model":"llama-3.1-8b",...} over vier chunks splitst.

Het tweede onderdeel dat je zelf moet schrijven, is wat er gebeurt wanneer validatie faalt:

// `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,
    };
}

Vóór de uitvoering kan de lookup de tool niet vinden, kan JSON.parse de argumenten afwijzen, of kan Zod hun vorm afwijzen. Elke fout wordt een message die het model kan lezen. Tijdens de uitvoering wordt ook een verwachte ToolError een tool result, zodat het model zijn call kan corrigeren. Een onverwachte exception propageert naar het HTTP error path in plaats van als domain failure aan het model te worden gepresenteerd. z.prettifyError zet Zod’s issue tree om in een message waarop het model kan reageren, in plaats van in een stack trace.

strict: true in een OpenAI function definition vraagt de provider om decoding te beperken tot het schema. Dit heeft niets te maken met de TypeScript-flag strict in tsconfig. Dit lijkt op guided decoding in vLLM, hoewel ondersteunde schema’s en enforcement-details verschillen. Het elimineert één failure mode, maar een self-hosted endpoint kan de flag negeren. De argumenten moeten ook JSON.parse doorstaan.

De loop roept /chat/completions aan omdat de companion zich richt op OpenAI-compatible servers. vLLM, SGLang en Ollama documenteren dat endpoint, zodat OPENAI_BASE_URL dezelfde client naar elk van deze servers kan laten wijzen. Hun ondersteuning voor de Responses API verschilt en verandert per release. Als je beide kanten beheert, controleer dan de actuele compatibility page van de server voordat je kiest tussen de twee API’s.


Stap daarna over op de AI SDK en weet wat je inlevert

Voor latere projecten zou ik de Vercel AI SDK gebruiken. De companion implementeert dezelfde agent twee keer, zodat de trade-off zichtbaar wordt. Beide versies emitten dezelfde AgentEvent-stream, waardoor de HTTP-laag ze niet van elkaar kan onderscheiden.

De companion pint AI SDK 7.0.42 vast in packages/agent-core/package.json. De frameworkimplementatie staat in 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;
        // ...
    }
}

De SDK verwijdert vijf stukken applicatiecode:

  • de fragmentaccumulator
  • JSON.parse en het bijbehorende error path
  • de call die Zod uitvoert op de geparseerde argumenten
  • provider-specifieke message assembly
  • de step counter

stopWhen accepteert verschillende voorwaarden, waaronder een step limit of een specifieke tool call. De for await-loop verandert niet wanneer de stop policy verandert.

Wat je opgeeft, is directe controle over een validation failure. De handgeschreven loop bepaalt wat het model ziet na een afgewezen call. De SDK biedt de experimentele experimental_repairToolCall-optie voor een custom repair callback. De huidige companion stelt die optie niet in en vertrouwt daarom op de standaard invalid-call handling van de SDK. De trade-off werkt ook de andere kant op. In de SDK-versie blijft een providerwijziging beperkt tot de provider adapter. Je hebt nog steeds het bijbehorende provider package, credentials, configuratie en integration tests nodig. In de raw loop zijn provider-specifieke request- en streamafhandeling code die je zelf moet aanpassen.

Ik schrijf de loop met de hand voor het eerste project en gebruik de SDK voor de volgende projecten. Die les betaal je één keer. Het alternatief is voor het eerst de internals van een framework lezen terwijl het in productie faalt.


Streaming over HTTP: Hono en SSE

Hono-routes lezen als FastAPI-routes. De enige toevoeging is zValidator, die doet wat FastAPI automatisch krijgt uit de type annotations op de signature van een handler. De c in de onderstaande handler is Hono’s request context, het object dat FastAPI over je parameters verdeelt. deps is een verzameling dependencies waarmee de app wordt geconstrueerd, in plaats van ze rechtstreeks te importeren. runAgent is er daar één van; in de testing-sectie zie je wat dat oplevert.

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

De route behandelt momenteel elke generator die normaal wordt voltooid hetzelfde. Hij slaat status: "succeeded" op nadat een generator het einde bereikt, ook wanneer het laatste event stopReason: "max_steps" heeft. Daardoor is de ceiling outcome completed but truncated or partial, niet een failure die al is opgeslagen. Production code moet het done-event inspecteren en expliciet beleid toepassen. Je kunt bijvoorbeeld een aparte truncated status gebruiken of eerst een review/retry-pad volgen voordat je success rapporteert.

zValidator valideert de body en geeft c.req.valid("json") het type dat het schema produceert. Als je dit overslaat, wordt de body getypt als any, TypeScript’s opt-out, waarbij elke property access compileert en niets wordt gecontroleerd. Daarmee schakel je het type-safetyvoordeel van het schema uit.

Deze route gebruikt Server-Sent Events in plaats van WebSockets. De server houdt een HTTP-response open terwijl hij event: <name>- en data: <json>-frames schrijft, en sluit die na het laatste event. Het verkeer loopt van server naar client, wat past bij deze agent stream. Een WebSocket zou bidirectionele messaging en een protocol upgrade toevoegen die deze route niet nodig heeft.

Een midstream failure verandert de HTTP error reporting. Zodra het eerste frame met status 200 is verstuurd, kan de server die response niet vervangen door een 500. Het catch-blok logt de opgevangen error, stuurt een constant error event naar de client en keert terug. Die return is belangrijk: alleen een succesvol voltooide stream bereikt storage.createRun. Een generator die done emit, inclusief max_steps, is een normaal voltooide stream en bereikt die write wel.

Een test dekt dit pad af. Een generator levert één tekstdelta en gooit daarna een exception. De response blijft 200 en het laatste frame is een error-event met de constante message Agent run failed. De logger bewaart de opgevangen error voor diagnose aan de serverkant. Een client die alleen de statuscode controleert, rapporteert een failed run als succesvol.

app.ts neemt twee kleinere beslissingen die uitleg verdienen. Het behandelt /healthz als een liveness endpoint, waardoor die route Postgres bewust niet aanraakt. Een liveness failure tijdens een database outage zou elke replica kunnen herstarten zonder de dependency te herstellen. Voeg een aparte readiness check toe wanneer de orchestrator moet stoppen met het routeren van traffic naar een instance die geen verbinding kan maken met Postgres. De error paths loggen de opgevangen error, maar retourneren een constante string. error.message terugsturen in een response body is hoe connection strings in de browser van iemand anders terechtkomen.


Het Celery-achtige onderdeel, zonder Celery

Lange jobs horen niet in een request handler thuis. De API voegt een row toe en retourneert 202. Een worker claimt de row.

Hier is geen Redis en ook geen BullMQ. PostgreSQL documenteert SKIP LOCKED voor meerdere consumers van een queue-achtige tabel. De clause geeft deze kleine service een at-least-once queue in één tabel. Die is transactioneel met de rest van je writes en betekent één service minder in docker-compose.yml.

De claim query in packages/agent-core/src/db/storage.ts is:

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

De row wordt voor de transaction gelockt en een gelijktijdige worker die dezelfde query uitvoert, slaat de row over in plaats van te blokkeren. Twee gelijktijdige claims ontvangen daardoor niet dezelfde niet-stale row. Een integration test voert via Promise.all twee claims tegelijk uit en assert dat ze verschillende rows retourneren. De naïeve versie, SELECT ... LIMIT 1 gevolgd door UPDATE, faalt voor deze test: beide transactions lezen dezelfde row voordat een van beide schrijft, waardoor ze allebei dezelfde job starten.

Dit is at-least-once execution, geen exactly-once execution. De volledige query claimt ook opnieuw een running row wanneer de lock daarvan meer dan vijf minuten oud is, en de demo-worker vernieuwt die lease niet. Een live job die langer dan vijf minuten draait, kan daardoor twee keer worden geclaimd. Maak jobs idempotent. Voeg voor langlopende work een lease heartbeat toe of zet de stale-lock threshold hoger dan de maximale runtime.

Voeg BullMQ toe wanneer je delayed jobs, repeatable schedules, priorities, rate limits of een dashboard nodig hebt. In Python zou ik dezelfde stap maken van een database table naar Celery. Daarvoor is Redis echter nog een service die je moet draaien, monitoren en uitleggen aan degene die on-call is.

De worker valideert opnieuw wat hij uit jsonb leest:

// 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);

De test suite voert ook een row aan de worker waarvan de seqLen een string is. De worker faalt de run en blijft pollen, in plaats van te crashen en dezelfde poison row eindeloos opnieuw te proberen.

CPU-werk legt nog een andere Node-beperking bloot. Een synchrone callback draait op de event-loop-thread en kan niet worden onderbroken. Een for-loop die twee seconden lang rekenwerk uitvoert, blokkeert gedurende die twee seconden elk request, elke timer en elke liveness-check in dat proces. Een tight loop binnen een async def blokkeert asyncio op dezelfde manier. Beide runtimes vereisen dat je CPU-werk expliciet offloadt.

await setTimeout(0) uit node:timers/promises (het prefix node: betekent standard library, dus node:timers is voor Node wat os is voor Python) is await asyncio.sleep(0). De sweep yieldt na elke batchgrootte, zodat het worker process timers en andere callbacks kan afhandelen. Yielden maakt CPU-werk niet parallel. Node worker_threads kan JavaScript parallel uitvoeren. Gebruik voor pure Python CPU-werk onder de gebruikelijke GIL-enabled CPython-build een process pool in plaats van een thread pool. Deze service gebruikt geen van beide. Houd zware numerieke berekeningen in Python, waar de ondersteunende libraries al aanwezig zijn, en verplaats ze van de API-event-loop.


Eén tool, drie consumers

EstimateKvCacheInput heeft drie consumers:

  • De handgeschreven loop converteert het met z.toJSONSchema.
  • De AI SDK ontvangt het ongewijzigd.
  • De MCP-server publiceert de vorm ervan.

Die hergebruiksmogelijkheid is waarom packages/schemas bestaat.

Uit 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());

Twee details zijn belangrijk voordat je een client aansluit. Ten eerste gebruikt een server die op deze manier is gestart zijn eigen stdin en stdout om met de client te communiceren. Elke regel is een JSON-RPC-bericht. Een verdwaalde console.log, het JavaScript-equivalent van print, maakt vervolgens een bericht ongeldig. De client verbreekt de verbinding met een parse error waarin geen bestandsnaam staat. Stuur alle diagnostics naar stderr.

Ten tweede moet een domain failure isError: true met een bericht retourneren. Het aanroepende model kan de call dan corrigeren, net zoals na ongeldige tool arguments in de agent-loop.

Je kunt de server aansturen met printf en een pipe. Dat is de moeite waard om één keer te doen voordat je er een echte client op aansluit:

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

De probe gebruikt dezelfde protocolversie als de bijbehorende README. Gebruik voor een echte client de SDK in plaats van JSON-RPC-berichten handmatig te onderhouden.


Een agent testen zonder API-key

Vitest vervult de rol van pytest, maar de structuur is anders. describe groepeert gerelateerde tests. it en test definiëren elk één test case. test.each lijkt op parametrize, beforeEach verzorgt setup per test, vi.fn() maakt een mock function aan en describe.skipIf slaat conditioneel een groep over.

Agent-tests zijn afhankelijk van één beslissing: runAgent ontvangt een OpenAI-client als parameter in plaats van er zelf één te construeren. De fake is een object met een chat.completions.create-methode die een scripted async iterable retourneert:

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

De tests splitsen één JSON-argumentstring op over meerdere chunks en verwerken twee tool calls in één response. Ze testen ook ongeldige batchSize, malformed JSON, onbekende toolnamen en een model dat tools blijft aanroepen totdat maxSteps het stopt. Het testbestand draait ruim binnen één seconde, zonder netwerkverbinding en zonder key.

Integratietests tegen Postgres gebruiken describe.skipIf(!process.env.DATABASE_URL), zodat pnpm test werkt op een verse clone zonder draaiende Postgres-instantie. CI schakelt deze tests in door de variabele aan te leveren. De repo bevat 40 tests. Zesendertig daarvan draaien zonder Postgres of een API-key.


Log structured events met Pino

Pino vervult dezelfde rol als structlog: één JSON-object per regel, child loggers met gebonden velden en expliciete redaction. De companion configureert Pino in packages/observability/src/logger.ts:

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

Zonder redaction kan log.info({ req }, "...") een Authorization-header naar de logbackend kopiëren.


Trace application work met manual spans

De companion gebruikt OpenTelemetry voor drie spans op applicatieniveau: agent.run, agent.tool en worker.sweep. Er wordt geen automatische HTTP- of Postgres-instrumentatie geïnstalleerd. startTracing() in packages/observability/src/tracing.ts maakt een NodeSDK aan met een OTLP trace exporter. Als OTEL_EXPORTER_OTLP_ENDPOINT ontbreekt, blijft tracing uitgeschakeld.

Het werk zelf wordt omwikkeld door withSpan() uit hetzelfde bestand:

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

JavaScript heeft geen Python-achtige syntax voor contextmanagers. Hier is de callback het blok dat door een Python-contextmanager zou worden omgeven. De volledige helper registreert ook exceptions en stelt de spanstatus in voordat de exception opnieuw wordt gegooid.

Automatische HTTP- en database-spans zijn een aparte feature. Hiervoor zijn de bijbehorende instrumentation packages en initialisatie nodig voordat de geïnstrumenteerde modules worden geladen. Voeg dit alleen toe wanneer die spans nuttig zijn en volg daarna de OpenTelemetry Node SDK setup voor de exacte packageversies die je uitrolt.


Ship de monorepo in Docker

Gebruik de meegeleverde Dockerfile. De container start de API met de tsx loader. Bij het deployen hoef je geen TypeScript runner te kiezen of aan te roepen.

De build gebruikt pnpm fetch, zodat dependency-downloads gecachet blijven totdat het lockfile verandert. Vervolgens gebruikt de build pnpm deploy om de API en de production dependencies naar een self-contained directory te kopiëren. De runtime stage draait als de non-root gebruiker node. De exec-form CMD zorgt ervoor dat de API SIGTERM rechtstreeks ontvangt voor graceful shutdown.

Waarom de Dockerfile tsx laadt

Node 24 kan een beperkte subset van TypeScript uitvoeren door type-annotaties te strippen. Het type-checkt de code niet en voert ook niet de transformaties uit die een volledige TypeScript runner ondersteunt. De package scripts van de repository verbergen dit detail. pnpm check voert de afzonderlijke static check uit.

De container maakt nog een andere limiet zichtbaar. pnpm deploy kopieert de workspace packages naar node_modules, en Node weigert daar bewust TypeScript te strippen (Node TypeScript-documentatie). De eerste versie van de image crashte met ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Het werkt. Ik raakte ontmoedigd.

Het Dockerfile lost het probleem op door tsx te laden, die deze .ts-bestanden verwerkt voordat Node ze uitvoert. Een team dat alleen .js-bestanden in zijn runtime-image wil, kan in plaats daarvan een compile-stap toevoegen. Dat is een alternatief production design, geen extra stap die nodig is om deze companion uit te voeren.


Een traject van drie weken

Ervaren Python-engineers kunnen materiaal over variabelen en loops overslaan. Deze reeks richt zich op de onderdelen die afwijken van Python. De kolom Build is het doel van elke rij. De leesstof ondersteunt dat doel.

WeekReadBuild
1javascript.info: alleen modules, promises en objects. Gebruik MDN’s JS Guide als referentie.Herschrijf één Python CLI in TypeScript. Voeg een package.json-script toe. Voer het script uit en pnpm typecheck.
1-2Lees TypeScript’s tsconfig reference, de gratis tutorials van Total TypeScript en de Zod docs.Bouw een door Zod gevalideerde config-module en één tagged union. Nadat de tag is gecontroleerd, weet de compiler welke variant het block bevat.
2Lees de documentatie van Hono, Drizzle, Vitest en Biome.Bouw een streaming proxy naar een OpenAI-compatible endpoint met een door Drizzle ondersteund log.
3Lees de documentatie van AI SDK en MCP TypeScript SDK.Bouw een tool-calling agent. Bouw daarna een MCP-server die een van zijn tools beschikbaar maakt.

Begin met de gratis tutorials van Total TypeScript. Betaal pas voor het advanced materiaal wanneer je werkt met library-grade generics en conditional types. Sla elke cursus “intro to JavaScript” over en sla alles in de stijl van React over, tenzij het product dat vereist.

Voor een overzicht van production conventions is goldbergyoni/nodebestpractices een brede, door de community onderhouden checklist. Controleer advies dat invloed heeft op runtimegedrag of security aan de hand van de actuele Node-documentatie.


Afwegingen

Numerieke berekeningen in Python houden

Node werkt goed voor orchestration, HTTP-serving en streaming. Aanhoudende CPU-bound berekeningen blokkeren de main event-loop-thread. Houd vLLM- en trainingcode in Python, tenzij metingen rechtvaardigen dat je deze verplaatst.

Elke boundary runtime valideren

Een TypeScript-annotatie controleert geen HTTP-body, environment variable, model-generated tool argument of uit jsonb gelezen row. Elke boundary heeft een runtime schema nodig.

SDK-churn isoleren

AI SDK 6 verving Experimental_Agent door ToolLoopAgent en hernoemde de agent setting system naar instructions (AI SDK 6 migration guide). De companion roept streamText rechtstreeks aan op AI SDK 7 en stelt zijn eigen AgentEvent-stream beschikbaar. Door die boundary blijft de HTTP-route ongewijzigd wanneer de SDK-code verandert.

De handgeschreven loop overslaan wanneer de deadline belangrijker is

Eenmalig zelf de loop schrijven leert je welk gedrag de SDK beheert. Als je vooral snel wilt shippen en geen reden hebt om validatiefouten aan te passen, begin dan met de SDK.


Belangrijkste punten

  1. Installeer Node 24 en pnpm. Gebruik daarna de scripts van de repository: pnpm demo, pnpm dev:api, pnpm dev:worker en pnpm check. De scripts verbergen de low-level runtime- en type-checkercommando’s.
  2. Runtime-validatie is structureel noodzakelijk. Zod is de gekozen validator van dit project. Static types inspecteren geen HTTP-bodies, environment variables, modeloutput of database-rows. Declareer met Zod een runtime schema en leid het TypeScript-type af met z.infer.
  3. De stack mapt grotendeels direct: pnpm voor uv, Hono voor FastAPI, Drizzle voor SQLAlchemy, Vitest voor pytest en Biome voor Ruff. Drie rijen zijn geen directe vervangingen: validatie, typechecking en de job queue.
  4. Schrijf één agent loop met de hand als je de verborgen paden wilt leren kennen of aanpassen: fragment accumulation, validatie en feedback over tool errors.
  5. Maak van de loop een async generator. De HTTP-route consumeert de AgentEvent-waarden, emit SSE-frames, accumuleert de tekst en persisteert die na de stream. Tests consumeren de generator afzonderlijk, zonder netwerk of API key.
  6. Postgres kan een at-least-once queue bieden. Maak handlers idempotent en vernieuw de lease, of stel die langer in dan de maximale runtime voor lange jobs. Voeg BullMQ toe wanneer je delays, priorities of schedules nodig hebt.
  7. Gebruik de meegeleverde Dockerfile voor production. Deze verpakt de geselecteerde app, laadt TypeScript met tsx, draait als non-root user en geeft shutdown-signalen door aan het API-proces.

Referenties

Demo-repository

  • slavadubrov/typescript-agent-service - de monorepo die in dit artikel wordt gebruikt: Hono API met SSE, twee implementaties van een agent loop, Drizzle-opslag, worker, MCP-server en 40 tests

Runtime en taal

Tooling

  • pnpm en pnpm installation - package manager, workspaces en setup
  • Biome - linting, formatting en import sorting in één binary
  • Vitest - test runner waarvoor geen transform-configuratie nodig is
  • Total TypeScript - gratis tutorials plus een betaald traject voor advanced types

Libraries

  • Zod - schema validation en type inference. Versie 4 bevat z.toJSONSchema.
  • Hono - Web-standard HTTP-framework
  • Drizzle ORM - SQL-first TypeScript-ORM met drizzle-kit-migraties
  • PostgreSQL SELECT-documentatie - de FOR UPDATE ... SKIP LOCKED-lockingclause
  • BullMQ - Redis-backed queue voor situaties waarin een databasetabel niet volstaat

AI en agents

Conventies