Automatische vertaling Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.
TypeScript voor Python ML-engineers: bouw een agent service
Dit is een snelle onboardinggids voor ervaren Python-engineers die AI-services in TypeScript en Node moeten opleveren. De gids 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, na jarenlang met Python en Java te hebben gewerkt. De meeste gidsen die ik vond begonnen met basisprogrammeren 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 herken je Python-gewoonten 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 offlinevoorbeeld, 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 bestrijken 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 in dit artikel is een bestand uit
slavadubrov/typescript-agent-service,
de companion repository die samen met dit artikel is gepubliceerd. De repository bevat een HTTP API,
twee versies van dezelfde agent loop, Postgres-runhistorie, een worker en een MCP-server. pnpm install && pnpm demo
start het offline HTTP/SSE-agentpad en de sweep-berekening van de worker 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 uit:
pnpm install
pnpm demo
pnpm check
pnpm demo test de HTTP-handler, agent loop en SSE-stream met een
gescript model. De opdracht roept ook de runSweep-berekening van de worker aan. De worker
of diens database queue wordt niet gestart. De demo heeft geen API-key,
database of Docker nodig. pnpm check voert de type checker, linter, formatter-check
en tests uit.
Voer het volgende 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 onderliggende Node- en TypeScript-commando’s achter benoemde pnpm-scripts, ongeveer
zoals een Python-project uv run-commando’s achter make-targets kan zetten. Voeg
npm install niet toe aan 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 verschillende taken:
- JavaScript is de taal.
- Node.js is de runtime, grofweg CPython voor JavaScript. Installeer voor dit project Node 24.
- Het npm registry is de package-index, ongeveer het equivalent van PyPI. Het
npm-commando wordt met Node meegeleverd en kan packages uit dat registry installeren. - pnpm is de package manager die deze repository gebruikt. Het installeert packages uit het
npm registry, beheert de monorepo-workspace en voert de commando’s uit die in
package.jsonzijn gedeclareerd. package.jsonis het projectmanifest, het dichtst bijpyproject.toml. Descripts-sectie geeft langere commando’s namen zoalsdemo,checkendev:api.- TypeScript is JavaScript met statische types. Het
tsc-commando controleert die types. De repository voert dit uit viapnpm checkofpnpm typecheck. - tsx voert
.ts-bestanden uit zonder aparte build. De developmentscripts gebruiken dewatch-modus om de API of worker na een sourcewijziging opnieuw te starten. Je roept het in deze gids niet rechtstreeks aan.
Gebruik in deze repository de pnpm-scripts. Node is de runtime binnen die scripts,
en het meegeleverde Dockerfile handelt production af.
De stack, gemapt vanuit Python
Het grootste deel van de mapping is saai, en dat is goed nieuws. Drie rijen zijn dat niet:
| Onderdeel | Python | TypeScript | Waarom het geen directe vervanging is |
|---|---|---|---|
| Validatie | pydantic | zod | Het schema is de bron. Het type wordt ervan afgeleid, niet andersom |
| Typecheck | mypy | TypeScript (pnpm typecheck) | Beide controleren sourcecode zonder data te valideren die 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-standard
Request- en Response-APIs 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 client-generation step. Raw SQL blijft beschikbaar wanneer de query builder een Postgres-clause niet netjes kan uitdrukken. Ik zou Prisma kiezen wanneer de generated client en bijbehorende tooling beter bij het team passen.
Biome voor linting en formatting
Biome verzorgt linting, formatting en import sorting met één binary en één configuratiebestand. Houd ESLint wanneer het project afhankelijk is van custom rules die Biome niet biedt.
Vitest voor tests
Vitest voert de .ts-tests van de companion uit zonder
aparte transformconfiguratie.
Lees de TypeScript-syntax hieronder
Houd deze tabel naast de servicevoorbeelden als naslagwerk.
| TypeScript | Python / opmerking |
|---|---|
(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 | de eigenschappen model en seqLen uit request halen |
const [first] = xs | first = xs[0]. Levert bij leegte undefined op, niet IndexError |
{ type: "error", message } | {"type": "error", "message": message}. Een kale 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, behalve dat cases doorvallen tenzij ze eindigen met break of return |
for await | itereren over een async generator |
i++ | verhoogt 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 andere binding die je opnieuw toewijst.
Een korte enum-vertaling
Voor states met stringwaarden gebruikt deze repository een object plus een afgeleid string-uniontype:
const Status = { Queued: "queued", Running: "running" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "queued" | "running"
Het object levert Status.Queued terwijl het programma draait. De regel type
staat tijdens typechecking alleen "queued" of "running" toe. Samen vullen
ze de twee rollen van deze Python-declaratie:
class Status(str, Enum):
QUEUED = "queued"
RUNNING = "running"
Je hoeft alleen het patroon te herkennen. as const behoudt de waarden van het object als
exacte strings in plaats van ze te verbreden naar een willekeurig string.
De zeven semantische verschillen die tijd kosten
De syntax-tabel helpt je door de voorbeelden heen. Deze semantische verschillen zijn waar Python-gewoonten bugs veroorzaken.
1. Lege arrays en objects zijn truthy
Python’s 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 meestal een bewuste afwezigheid. undefined betekent meestal dat
een waarde ontbreekt of niet toegewezen is, hoewel code dit expliciet kan toewijzen. Librarycode
retourneert voortdurend undefined. Het verschil wordt zichtbaar wanneer je een default
schrijft. || vervangt de linkerzijde telkens wanneer die falsy is. Dat omvat
0, "" en false. ?? vervangt alleen null en
undefined. Daarom is 0 || 10 gelijk aan 10, terwijl 0 ?? 10
gelijk is aan 0. Zo verandert een batch size van nul ongemerkt in tien.
3. Een catch-block ontvangt unknown
Er is geen except ValueError:. Eén catch-block vangt alles op. Omdat JavaScript een
string, getal of null kan throwen, typecheckt TypeScript de gevangen waarde als
unknown, het type “dit kan letterlijk alles zijn” onder strict. De companion
schakelt strict in, wat voor nieuwe projecten algemeen wordt aanbevolen. Om de
error te inspecteren, narrow je de waarde eerst:
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 direct
Het aanroepen van een async-functie start de uitvoering van de body en retourneert een promise.
Een Python coroutine object doet niets totdat je het await of scheduled. 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 proces blijft actief zolang er actieve handles of
requests bestaan, zoals timers en sockets. Alleen een gewone pending promise houdt Node niet in leven.
Je wikkelt het programma niet in asyncio.run. In een ES-module kun je op top-level
await gebruiken wanneer de startup op een async operatie moet wachten.
5. JavaScript heeft één regulier numeriek type
JavaScript’s number-type slaat waarden op als 64-bit floating-pointgetallen, ongeveer
hetzelfde als Python’s float. De technische standaard voor dit formaat heet IEEE 754.
Decimalen zijn approximaties: 0.1 + 0.2 is niet exact 0.3, en integers blijven
alleen exact tot 2**53 - 1, oftewel 9,007,199,254,740,991.
Houd 64-bit-ID’s als strings bij service boundaries. Een Postgres-bigint converteren naar
een JavaScript-number kan afronding veroorzaken. Voor grotere exacte integers biedt JavaScript
het afzonderlijke BigInt-type, dat niet met gewone numbers kan worden gemengd.
6. Gebruik Map voor een Python-style dictionary
In JavaScript maakt {} een object. Objects representeren meestal records met
benoemde velden:
const request = { model: "gpt-5", seqLen: 4096 };
console.log(request.model);
Een object is geen nette key-value-tabel 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 leest. Gebruik Map als je een algemene dictionary nodig hebt. Map
komt dichter bij 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 moduleformaat, 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 ./env.ts of ./env.js betekent:
import { loadEnv } from "./env.ts";
Imports uit geïnstalleerde of workspace-packages gebruiken nog steeds de packagenaam, zonder file extension:
import { z } from "zod";
import { runAgent } from "@agent/core";
Zod is pydantic met de pijl omgekeerd
In pydantic declareer je een class en krijg je een validator. In Zod declareer je een validator en leid je het type daarvan 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() handelt het
feit af dat elke gedefinieerde waarde in process.env (Node’s os.environ) een string
is. Het vervult dezelfde rol als pydantic’s numeric settings coercion, hoewel de exacte strings die
elk accepteert verschillen. Dit volgt het pydantic-settings-patroon en draait één keer bij startup.
Een ongeldige environment levert dan een leesbare startup-fout op 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 scheme. Daardoor leest de standaard localhost: als een protocol met de
naam “localhost” en accepteert de string. De waarde bereikt vervolgens de HTTP-client en faalt met
minder context. Schema-validatie verplaatst fouten naar voren, maar handhaaft een permissief schema als
dat is wat je hebt geschreven.
Zod 4 levert ook z.toJSONSchema, dus dit project heeft de in oudere tutorials veelgebruikte
zod-to-json-schema-dependency niet nodig. Dat is belangrijk zodra één schema drie consumers moet
bedienen, zoals in de sectie “Eén tool, drie consumers” hieronder.
De service
De demo-service schat LLM-deployments. Eén tool zoekt architectuurconstanten van een model op. De andere schat de omvang van de KV cache: het GPU-geheugen dat wordt gebruikt om de attention-keys en -values voor in-flight requests vast te houden. Beide tools doen bewust saaie rekenkunde. Ze hebben geen netwerk nodig en geven altijd hetzelfde antwoord. Daardoor is de service zonder API-key te testen. De KV-cache-estimator wordt ook via het Model Context Protocol (MCP) gepubliceerd, zodat andere AI-clients hem 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 dat de workspace declareert. Interne packages krijgen een scoped naam zoals
@agent/core, waarbij het @agent/-prefix een naming convention is, geen
taalfunctie. Elke package declareert zijn publieke entry point in package.json. Die package boundary
hangt niet af van het commando waarmee de applicatie wordt gestart.
Deze private workspace wijst die entries naar .ts-source, omdat elke consumer onderdeel
is 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 zelf
Tool-calling agent-frameworks verpakken dezelfde basisloop:
- Roep het model aan met tool definitions.
- Valideer en voer de gevraagde tools uit.
- Voeg de resultaten toe aan de messages.
- Roep het model opnieuw aan.
Schrijf deze loop één keer zelf. Frameworkgedrag wordt dan een engineeringkeuze die je kunt onderbouwen.
De loop is een async function*, een async generator, exact de vorm van Python’s async def
met yield. De HTTP-route itereert over die generator, zet elk event om in een Server-Sent
Events-frame en accumuleert de tekst. Nadat de stream eindigt, 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 volgende sectie legt het
formaat uit.
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 lokale variabele text bevat één modelstap. De loop gebruikt deze in het assistant
message voor de volgende stap of in het uiteindelijke done-event. Dit is niet de
route-level accumulator die later wordt opgeslagen.
De partial-map is het deel dat frameworks verbergen. De SDK stelt stringfragmenten van de
function arguments beschikbaar, en die kunnen de geserialiseerde JSON op willekeurige posities splitsen.
Meerdere parallelle calls kunnen bovendien interleaven. De repository bevat een test die {"model":"llama-3.1-8b",...}
over vier chunks splitst.
Het tweede dat je zelf zou moeten 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,
};
}
Voor execution kan de lookup de tool niet vinden, kan JSON.parse de arguments afwijzen of kan
Zod hun vorm afwijzen. Elke fout wordt een message die het model leest. Tijdens execution wordt ook een
verwachte ToolError een tool result, zodat het model zijn call kan corrigeren. Een onverwachte
exception propageert naar het HTTP-errorpad in plaats van als domain failure te worden gepresenteerd.
z.prettifyError zet Zod’s issue tree om in een message waarmee het model kan handelen, in plaats van
in een stack trace.
strict: true op een OpenAI-function
definition vraagt de provider om constrained decoding volgens het schema.
Dit heeft geen relatie met TypeScript’s strict-tsconfigflag. Het lijkt op vLLM’s guided
decoding, hoewel ondersteunde schemas en enforcementdetails verschillen. Het verwijdert één failure mode,
maar een self-hosted endpoint kan de flag negeren. De arguments moeten ook JSON.parse overleven.
De loop roept /chat/completions aan omdat de companion OpenAI-compatible servers target. vLLM,
SGLang en Ollama documenteren dat endpoint, dus OPENAI_BASE_URL kan
dezelfde client naar elk van deze systemen laten wijzen. Hun Responses API-ondersteuning verschilt en
verandert per release. Als je beide kanten beheert, controleer dan de huidige compatibility-pagina van
de server voordat je tussen de twee APIs kiest.
Schakel daarna over naar de AI SDK en weet wat je inruilt
Voor latere projecten zou ik de Vercel AI SDK gebruiken. De companion implementeert dezelfde
agent twee keer, zodat de trade-off zichtbaar is. Beide versies emitten dezelfde AgentEvent-stream,
waardoor de HTTP-laag geen verschil ziet.
De companion pint AI SDK 7.0.42 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 application code:
- de fragment accumulator
JSON.parseen het error path daarvan- de call die Zod over de geparste arguments uitvoert
- 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 validation failures. De handgeschreven loop bepaalt wat het model
ziet na een afgewezen call. In de SDK-versie configureer je dat gedrag via repairToolCall.
De trade-off werkt ook andersom. In de SDK-versie is een providerwijziging beperkt tot de provider adapter.
Daarvoor zijn nog steeds het bijbehorende provider package, credentials, configuratie en integration tests
nodig. In de raw loop zijn provider-specifieke request- en stream-handling jouw code om te wijzigen.
Ik schrijf de loop in het eerste project zelf en gebruik de SDK in latere 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 toevoeging is zValidator, dat doet wat FastAPI
gratis 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 dependency-bag waarmee de app wordt geconstrueerd, in plaats van dependencies
direct te importeren. runAgent is er één van; de testsectie laat zien 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 },
});
});
});
zValidator valideert de body en geeft c.req.valid("json") het type dat het schema produceert.
Sla dit over en de body krijgt type any, TypeScript’s opt-outtype, 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 stroomt van server naar client, wat bij deze agentstream past. Een WebSocket zou
bidirectionele messaging en een protocolupgrade toevoegen die deze route niet nodig heeft.
Een failure midstream verandert de HTTP-error reporting. Zodra het eerste frame met status 200 is verzonden,
kan de server die response niet meer vervangen door een 500. Het catch-block logt de
opgevangen error, stuurt een constant error-event naar de client en retourneert. Die return is belangrijk:
alleen een succesvol voltooide stream bereikt storage.createRun.
Een test dekt dit pad. Een generator yieldt één text delta 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 serverzijde. Elke client die alleen de statuscode
controleert, rapporteert succes bij een mislukte run.
app.ts maakt twee kleinere keuzes die uitleg verdienen. Het behandelt /healthz als
liveness-endpoint, zodat die route Postgres bewust niet aanraakt. Een liveness-failure tijdens een
database-outage kan elke replica herstarten zonder de dependency te herstellen. Voeg een afzonderlijke
readiness-check toe wanneer de orchestrator verkeer moet stoppen naar een instance die Postgres niet kan
bereiken. De error paths loggen de opgevangen error, maar retourneren een constante string. error.message
in een response body echoën is hoe connection strings in de browser van iemand anders belanden.
Het Celery-achtige onderdeel, zonder Celery
Lange jobs horen niet in een request handler. De API voegt een rij toe en retourneert 202. Een worker claimt de rij.
Hier is geen Redis en 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. De queue is transactioneel met
de rest van je writes, en éé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 rij wordt voor de transactie gelockt en een gelijktijdige worker die dezelfde query uitvoert, slaat de
rij over in plaats van te blokkeren. Twee gelijktijdige claims ontvangen daardoor niet dezelfde niet-stale
rij. Een integration test voert twee claims tegelijk uit via Promise.all en controleert dat ze
verschillende rijen retourneren. De naïeve versie, SELECT ... LIMIT 1 gevolgd door UPDATE,
faalt voor deze test: beide transacties lezen dezelfde rij voordat een van beide schrijft, zodat ze dezelfde
job starten.
Dit is at-least-once execution, geen exactly-once execution. De volledige query claimt ook een running-rij
opnieuw wanneer de lock meer dan vijf minuten oud is; de demo-worker vernieuwt die lease niet. Een actieve job
die langer dan vijf minuten draait kan daardoor tweemaal worden geclaimd. Maak jobs idempotent. Voeg voor
langlopende werkzaamheden een lease-heartbeat toe of zet de stale-lockdrempel boven 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 databasetabel naar Celery. Daarvoor is Redis echter nog een service om te draaien, monitoren en uit te leggen 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 testsuite geeft de worker ook een rij waarvan 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 een andere Node-beperking bloot. Een synchrone callback draait op de event-loop-thread en wordt
niet gepreëmpt. Een for-loop die twee seconden lang rekenwerk uitvoert, blokkeert
gedurende die twee seconden elke request, timer en 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 node:-prefix betekent
standard library, dus node:timers is voor Node wat os is voor Python) is
await asyncio.sleep(0). De sweep yieldt na elke batch size, zodat het workerproces timers en andere callbacks
kan afhandelen. Yielding maakt CPU-werk niet parallel. Node
worker_threads kan JavaScript parallel uitvoeren. Gebruik voor pure
Python CPU-workloads 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 weg 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 reuse 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 koppelt. Ten eerste: een server die op deze manier
wordt gestart, gebruikt zijn eigen stdin en stdout om met de client te communiceren. Elke regel is
een JSON-RPC-message. Een verdwaalde console.log, het JavaScript-equivalent
van print, corrumpeert vervolgens een message. De client verbreekt de verbinding met een
parse error waarin geen bestandsnaam staat. Stuur alle diagnostiek naar stderr.
Ten tweede hoort een domain failure isError: true met een message te 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. Doe dat één keer voordat je een echte client
erop richt:
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 komt overeen met de protocolversie die in de README van de companion wordt gebruikt. Gebruik voor een echte client de SDK in plaats van JSON-RPC-messages met de hand te onderhouden.
Een agent testen zonder API-key
Vitest vervult de rol van pytest, maar de structuur verschilt. describe groepeert
gerelateerde tests. it en test definiëren elk één testcase. test.each
lijkt op parametrize, beforeEach verzorgt per-test setup, vi.fn() maakt een mock
function en describe.skipIf skipt conditioneel een groep.
Agent-tests hangen af van één keuze: 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
gescripte 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 over chunks en verwerken twee tool calls in één response. Ze
dekken 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 netwerk en zonder key.
Integration tests tegen Postgres gebruiken describe.skipIf(!process.env.DATABASE_URL), zodat pnpm test werkt op een
verse clone zonder draaiende Postgres; CI schakelt ze in door de variabele aan te leveren. De repository heeft
40 tests. Zesendertig draaien zonder Postgres of API-key.
Structured events loggen met Pino
Pino vervult dezelfde rol als structlog: één JSON-object per regel, child loggers
met gebonden fields en expliciete redaction. De companion configureert dit 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 log-backend kopiëren.
Application work tracen met manual spans
De companion gebruikt OpenTelemetry voor drie application-level spans:
agent.run, agent.tool en worker.sweep. Er wordt geen automatische HTTP- of
Postgres-instrumentation geïnstalleerd. startTracing() in
packages/observability/src/tracing.ts maakt een NodeSDK 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-style context-manager-syntax. Hier is de callback het block dat een Python context manager zou omringen. De volledige helper registreert ook exceptions en zet de spanstatus voordat de exception opnieuw wordt gegooid.
Automatische HTTP- en databasespans zijn een aparte feature. Ze vereisen de bijbehorende instrumentation packages en initialisatie voordat de geïnstrumenteerde modules worden geladen. Voeg dit alleen toe wanneer die spans nuttig zijn en volg voor de exacte packageversies die je deployt de OpenTelemetry Node SDK setup.
De monorepo shippen in Docker
Gebruik de meegeleverde
Dockerfile.
De container start de API met de tsx-loader. Bij deployment 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. Daarna 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 node-user.
De exec-form CMD laat de API SIGTERM rechtstreeks ontvangen voor graceful shutdown.
Waarom de Dockerfile tsx laadt
Node 24 kan een beperkte TypeScript-subset uitvoeren door type annotations te strippen. Het voert geen
typecheck uit en doet niet de transformaties die een volledige TypeScript runner ondersteunt. De package
scripts van de repository verbergen dat detail. pnpm check voert de afzonderlijke static check uit.
De container legt nog een beperking bloot. pnpm deploy kopieert de workspace-packages onder
node_modules, en Node weigert daar bewust TypeScript te strippen (Node TypeScript
documentation). De eerste image crashte met ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Het werkt.
Ik raakte ontmoedigd.
De Dockerfile verhelpt dit door tsx te laden, dat deze .ts-bestanden
afhandelt voordat Node ze uitvoert. Een team dat alleen .js-bestanden in zijn runtime-image
wil, kan in plaats daarvan een compile step toevoegen. Dat is een alternatief production design, geen extra
stap die nodig is om deze companion te draaien.
Een traject van drie weken
Ervaren Python-engineers kunnen materiaal over variables en loops overslaan. Deze sequence focust op de onderdelen die van Python verschillen. De build-kolom is het doel van elke rij. De reading ondersteunt dat.
| Week | Lezen | Bouwen |
|---|---|---|
| 1 | javascript.info: alleen modules, promises en objects. Houd MDN’s JS Guide bij de hand als referentie. | Herschrijf één Python-CLI in TypeScript. Voeg een package.json-script toe. Voer het script en pnpm typecheck uit. |
| 1-2 | Lees TypeScript’s tsconfig reference, de gratis tutorials van Total TypeScript en de Zod-docs. | Bouw een Zod-gevalideerde configmodule en één tagged union. Na controle van de tag 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 ondersteunde log. |
| 3 | Lees de documentatie van de AI SDK en de MCP TypeScript SDK. | Bouw een tool-calling agent. Bouw daarna een MCP-server die één van zijn tools exposeert. |
Begin met de gratis Total TypeScript-tutorials. Betaal pas voor het advanced materiaal wanneer je werkt met library-grade generics en conditional types. Sla elke “introductie tot JavaScript”-cursus over, en ook alles wat React-gericht is tenzij het product dat vereist.
Voor een overzicht van production conventions is goldbergyoni/nodebestpractices een brede, door de community onderhouden checklist. Controleer advies dat runtimegedrag of security beïnvloedt aan de hand van de actuele Node-documentatie.
Trade-offs
Houd numerieke berekeningen in Python
Node werkt goed voor orchestration, HTTP-serving en streaming. Aanhoudend CPU-bound rekenwerk blokkeert de main event-loop-thread. Houd vLLM en trainingscode in Python, tenzij een gemeten workload verplaatsing rechtvaardigt.
Valideer elke boundary tijdens runtime
Een TypeScript-annotatie controleert geen HTTP-body, environment variable, door het model gegenereerde
tool argument of rij die uit jsonb wordt gelezen. Elke boundary heeft een runtime-schema nodig.
Isoleer SDK-churn
AI SDK 6 verving Experimental_Agent door ToolLoopAgent en hernoemde de agentsetting
system naar instructions (AI SDK 6 migration guide).
De companion roept streamText rechtstreeks aan op AI SDK 7 en exposeert zijn eigen
AgentEvent-stream. Die boundary houdt de HTTP-route ongewijzigd wanneer SDK-code verandert.
Sla de handgeschreven loop over wanneer de deadline belangrijker is
De loop één keer schrijven leert je welk gedrag de SDK beheert. Als je eerst moet shippen en geen reden hebt om validation failures aan te passen, begin dan met de SDK.
Belangrijkste takeaways
- Installeer Node 24 en pnpm. Gebruik daarna de repository-scripts:
pnpm demo,pnpm dev:api,pnpm dev:workerenpnpm check. De scripts verbergen de onderliggende runtime- en typecheckercommando’s. - Runtime-validatie is structureel noodzakelijk. Zod is de gekozen validator van dit project.
Static types inspecteren geen HTTP-bodies, environment variables, modeloutput of databaseryen. Met Zod
declareer je een runtime-schema en leid je het TypeScript-type af met
z.infer. - De stack mapt grotendeels netjes: pnpm voor uv, Hono voor FastAPI, Drizzle voor SQLAlchemy, Vitest voor pytest en Biome voor Ruff. Drie rijen zijn geen vervangingen: validatie, typechecking en de job queue.
- Schrijf één agent loop zelf wanneer je de verborgen paden wilt leren of aanpassen: fragment accumulation, validatie en feedback over tool-errors.
- Maak de loop een async generator. De HTTP-route consumeert de
AgentEvent-waarden, emit SSE-frames, accumuleert de tekst en persist deze na de stream. Tests consumeren de generator afzonderlijk, zonder netwerk of API-key. - Postgres kan een at-least-once queue leveren. Maak handlers idempotent en vernieuw de lease, of maak die langer dan de maximale runtime voor lange jobs. Voeg BullMQ toe wanneer je delays, priorities of schedules nodig hebt.
- Gebruik de meegeleverde Dockerfile voor production. Die verpakt de geselecteerde app, laadt TypeScript met
tsx, draait als non-root-user en stuurt shutdown-signalen door naar het API-proces.
Referenties
Demo repository
- slavadubrov/typescript-agent-service - de monorepo die in dit artikel wordt gebruikt: Hono-API met SSE, twee agent-loopimplementaties, Drizzle-storage, worker, MCP-server en 40 tests
Runtime en taal
- Running TypeScript natively in Node.js - Node’s beperkte TypeScript-ondersteuning en de
node_modules-restrictie - TypeScript compiler options -
stricten de andere checks die door de companion zijn geconfigureerd - MDN JavaScript Guide - de taalreferentie die je open 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 - lint, format en import sorting in één binary
- Vitest - test runner zonder transformconfiguratie
- Total TypeScript - gratis tutorials plus een betaald traject voor advanced types
Libraries
- Zod - schema-validatie en type inference. Versie 4 bevat
z.toJSONSchema. - Hono - Web-standard HTTP-framework
- Drizzle ORM - SQL-first TypeScript ORM met
drizzle-kit-migrations - PostgreSQL SELECT documentation - de
FOR UPDATE ... SKIP LOCKED-lockingclause - BullMQ - Redis-backed queue wanneer de 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