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
npmwordt 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.jsonzijn gedeclareerd. package.jsonis het projectmanifest, het dichtst bijpyproject.toml. De sectiescriptsbevat namen zoalsdemo,checkendev:apivoor langere opdrachten.- TypeScript is JavaScript met statische types. De opdracht
tsccontroleert die types. De repository voert deze uit viapnpm checkofpnpm typecheck. - tsx voert
.ts-bestanden uit zonder afzonderlijke build. De developmentscripts gebruiken de moduswatchom 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
Het grootste deel van de mapping is rechttoe rechtaan, en dat is goed nieuws. Drie rijen zijn dat niet:
| Onderdeel | Python | TypeScript | Waarom dit geen één-op-één-vervanging is |
|---|---|---|---|
| Validation | pydantic | zod | Het schema is de bron. Het type wordt daaruit gegenereerd, niet andersom |
| Type check | mypy | TypeScript (pnpm typecheck) | Beide controleren source zonder data te valideren die tijdens runtime binnenkomt |
| Job queue | celery + Redis | bullmq (Redis-backed queue) of SQL | Postgres 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.
| TypeScript | Python / toelichting |
|---|---|
(x) => expression | anonieme 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 } = request | haal de eigenschappen model en seqLen uit request |
const [first] = xs | first = 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 : b | a if cond else b |
const / let | Beide binden een naam. const verbiedt rebinding, terwijl let dit toestaat |
export | maakt een naam importeerbaar |
switch / case | match, maar cases vallen door tenzij ze eindigen in break of return |
for await | itereren 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 syntaxistabel 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
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
Tool-calling agent-frameworks verpakken dezelfde basisloop:
- Roep het model aan met tool definitions.
- Valideer de aangevraagde tools en voer ze uit.
- Voeg de resultaten toe aan de messages.
- 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.parseen 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.
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.
| Week | Read | Build |
|---|---|---|
| 1 | javascript.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-2 | Lees 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. |
| 2 | Lees de documentatie van Hono, Drizzle, Vitest en Biome. | Bouw een streaming proxy naar een OpenAI-compatible endpoint met een door Drizzle ondersteund log. |
| 3 | Lees 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
- Installeer Node 24 en pnpm. Gebruik daarna de scripts van de repository:
pnpm demo,pnpm dev:api,pnpm dev:workerenpnpm check. De scripts verbergen de low-level runtime- en type-checkercommando’s. - 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. - 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.
- 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.
- 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. - 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.
- 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
- TypeScript native uitvoeren in Node.js - de beperkte TypeScript-ondersteuning van Node en de beperking van
node_modules - Compileropties voor TypeScript -
stricten de overige checks die door de bijbehorende configuratie worden ingesteld - MDN JavaScript Guide - de language reference die je bij de hand wilt houden
- javascript.info - moderne JavaScript-tutorial. Lees de hoofdstukken over modules en promises.
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
- Vercel AI SDK -
streamText,tool,stopWhenen provider adapters - openai/openai-node - de officiële TypeScript-client
- MCP TypeScript SDK en de MCP-specificatie - servers en clients bouwen
Conventies
- goldbergyoni/nodebestpractices - door de community onderhouden checklist voor production conventions