TypeScript voor Python ML-engineers: bouw een agent service

Automatische vertaling

Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.

Deze gids is voor ervaren Python ML- en backend-engineers die een AI-service in TypeScript en Node moeten bouwen. Hij vertrekt vanuit Python-servicebegrippen, niet vanuit basisprogrammeren of browsercode. Aan het eind kun je een Python-servicestack vertalen naar TypeScript, de Python-gewoonten herkennen die JavaScript-bugs veroorzaken, en een streaming agent service draaien en aanpassen.

De voorbeeldservice volgt een request door validatie, tool-uitvoering, HTTP-streaming en opslag. Hij beantwoordt drie vragen: wat gebeurt er als tool-argumenten in fragmenten binnenkomen, als een stream faalt na HTTP 200, of als een worker dezelfde job twee keer claimt? Het bereik is alleen backend- en AI-werk: geen React en geen browserbundler.

De code staat in slavadubrov/typescript-agent-service. Die bevat twee implementaties van de agent loop, Postgres-runhistorie, een worker en een MCP-server. Je kunt de offline demo zonder API-sleutel draaien en daarna de Python-naar-TypeScript-kaart hieronder gebruiken om de service te lezen en aan te passen. Zware numerieke berekeningen blijven in Python; deze gids behandelt orchestration, HTTP en streaming.

Draai eerst de companion

Installeer Node 24 en pnpm via de officiële pnpm-installatie-instructies. Kloon daarna de companion-repository en draai:

pnpm install
pnpm demo
pnpm check

pnpm demo laat de HTTP-handler, de agent loop en de SSE-stream draaien met een gescript model. Het roept ook de runSweep-berekening van de worker aan. Het start het worker-proces en de bijbehorende databasequeue niet. De demo heeft geen API-sleutel, database of Docker nodig. pnpm check draait de type checker, de linter, de formattercontrole en de tests.

De demo toont de SSE-frames van één chatrequest en daarna de sweep van de worker. Ingekort:

event: tool_call
data: {"type":"tool_call","callId":"call_1","toolName":"estimate_kv_cache","args":{"model":"llama-3.1-8b","seqLen":32768,"batchSize":16}}

event: tool_result
data: {"type":"tool_result","callId":"call_1","toolName":"estimate_kv_cache","ok":true,"result":{"model":"llama-3.1-8b","seqLen":32768,"batchSize":16,"kvCacheGiB":64},"durationMs":0}

event: done
data: {"type":"done","stopReason":"stop","steps":2,"text":"Llama-3.1-8B at 32k context and batch 16 needs 64 GiB of KV cache."}

--- worker: capacity sweep ---
batch=  1  kv_cache=4.0 GiB
batch= 16  kv_cache=64.0 GiB

Elk event:/data:-blok is één SSE-frame: het model vraagt om een tool, de tool geeft een resultaat terug en de run eindigt met done. Het laatste deel is de sweep over batchgroottes van de worker.

Zo draai je de echte API en worker:

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

Stream een agent-run. Het antwoord is een reeks SSE-frames zoals in de demo-uitvoer hierboven:

curl -N -X POST localhost:8080/v1/chat \
  -H 'content-type: application/json' \
  -d '{"message":"How much KV cache for llama-3.1-70b at 32k context, batch 8?"}'

Zet een lange job in de queue en poll hem daarna. De eerste aanroep geeft 202 terug met {"id":"...","status":"queued"}. De tweede geeft de opgeslagen rij terug, met het resultaat zodra de worker klaar is:

curl -X POST localhost:8080/v1/sweeps \
  -H 'content-type: application/json' \
  -d '{"model":"llama-3.1-70b","seqLen":32768,"batchSizes":[1,4,16]}'

curl localhost:8080/v1/runs/<id>

De pnpm-scripts hierboven zijn de commando’s die ik in de rest van dit artikel gebruik. De repository zet de lagere Node- en TypeScript-commando’s achter benoemde pnpm-scripts, zoals een Python-project uv run-commando’s achter make-targets kan zetten. Meng geen npm install in deze pnpm-repository. Gebruik pnpm install, zodat pnpm-lock.yaml het enige lockfile blijft.


Wat Node, npm, pnpm, TypeScript en tsx doen

De bijna gelijke namen verbergen aparte taken:

  • JavaScript is de taal.
  • Node.js is de runtime, ongeveer CPython voor JavaScript. Installeer Node 24 voor dit project.
  • Het npm-register is de pakketindex, ongeveer PyPI. Het npm-commando wordt met Node meegeleverd en kan pakketten uit dat register installeren.
  • pnpm is de package manager die deze repository heeft gekozen. Hij installeert vanuit het npm-register, beheert de monorepo-workspace en draait de commando’s die in package.json staan.
  • package.json is het projectmanifest, het dichtst bij pyproject.toml. De sectie scripts geeft namen als demo, check en dev:api aan langere commando’s.
  • TypeScript is JavaScript met statische types. Het tsc-commando controleert die types. De repository draait het via pnpm check of pnpm typecheck.
  • tsx draait .ts-bestanden zonder aparte build. De development-scripts gebruiken de watch-modus om de API of worker opnieuw te starten na een wijziging in de broncode. In deze gids roep je het niet rechtstreeks aan.

Voor deze repository draai je de pnpm-scripts. Node is de runtime binnen die scripts, en de meegeleverde Dockerfile regelt productie.

Wat elk script uitvoert:

  • pnpm demo draait tsx scripts/demo.ts.
  • pnpm dev:api draait tsx watch.
  • pnpm check draait tsc, Biome (de linter) en Vitest (de testrunner).
  • De Docker-CMD draait node --import tsx/esm.

Dit alles draait op Node 24. In pnpm check lezen tsc en Biome alleen bestanden; Vitest draait de tests op Node.


De stack, vertaald vanuit Python

Zod, Hono, Drizzle, Vitest en Biome dekken een groot deel van hetzelfde terrein als pydantic, FastAPI, SQLAlchemy, pytest en Ruff.

De meeste paren zijn directe vervangingen. Drie niet:

OnderdeelPythonTypeScriptWaarom het geen directe vervanging is
ValidatiepydanticzodHet schema is de bron. Het type wordt eruit afgeleid, niet andersom
TypecontrolemypyTypeScript (pnpm typecheck)Types worden verwijderd voordat de code draait. Geen enkele library kan ze lezen, dus runtime-validatie heeft een apart schema nodig (Zod)
Jobqueuecelery + Redisbullmq (queue op Redis-basis) of SQLPostgres kan een at-least-once queue implementeren. Een broker heb je misschien niet nodig

De companion gebruikt vier libraries die uitleg verdienen.

Hono voor de HTTP-laag

Express en Fastify zijn op Node gerichte alternatieven. Hono gebruikt de webstandaard Request- en Response-API’s en levert adapters voor Node en serverless runtimes. Die portabiliteit is nuttig voor deze kleine streaming-API, dus koos ik Hono. Kies Fastify als de service alleen op Node draait en je de plugins en de schemagebaseerde responsserialisatie wilt.

Drizzle voor SQL

Drizzle houdt het schema in TypeScript en heeft geen stap nodig om een client te genereren. Het maakt ook rauwe SQL beschikbaar wanneer de query builder een Postgres-clausule niet netjes kan uitdrukken. Ik zou in plaats daarvan Prisma kiezen als de gegenereerde client en de omliggende tooling beter bij het team passen.

Biome voor linting en formatting

Biome doet linting, formatting en het sorteren van imports met één binary en één configuratiebestand. Houd ESLint als het project afhangt van eigen regels die Biome niet biedt.

Vitest voor tests

Vitest draait de .ts-tests van de companion zonder aparte transformatieconfiguratie. Houd Jest in een repository die al een Jest-configuratie en eigen matchers heeft.


Lees de TypeScript-syntaxis die hieronder wordt gebruikt

Houd deze tabel naast de servicevoorbeelden als naslag.

TypeScriptPython / opmerking
(x) => expressionanonieme functie met een expressie als body, vergelijkbaar met lambda x: expression
(x) => { statements }anonieme functie met een body van statements
async (x) => { statements }async anonieme functie
const { model, seqLen } = requesthaalt de properties model en seqLen uit request
const [first] = xsfirst = xs[0]. Bij een lege lijst geeft het undefined, geen IndexError
{ type: "error", message }{"type": "error", "message": message}. Een kale naam wordt dat veld
`text ${x}`f-string
cond ? a : ba if cond else b
const / letBeide binden een naam. const verbiedt opnieuw binden, let staat het toe
exportmaakt een naam importeerbaar
switch / casematch, behalve dat cases doorvallen tenzij ze eindigen op break of return
for awaititereren over een async generator
i++verhoogt en geeft de oude waarde terug
/^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 teller, een accumulator of een andere binding die je opnieuw toewijst.

Een korte enum-vertaling

Voor stringwaardige statussen 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 type-regel staat tijdens het typecontroleren 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 houdt de waarden van het object de exacte strings, in plaats van ze te verbreden tot een willekeurige string.


De zeven semantische verschillen die tijd kosten

De syntaxistabel brengt je door de voorbeelden. Bij deze semantische verschillen veroorzaken Python-gewoonten bugs.

1. Lege arrays en objecten zijn truthy

Het Python-principe “een lege container is falsy” is de gewoonte die het slechtst overdraagt. if (results) is waar 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 is toegewezen, al kan code het ook expliciet toewijzen. Librarycode geeft voortdurend undefined terug. Het verschil wordt zichtbaar zodra je een standaardwaarde schrijft. || vervangt de linkerkant telkens als die falsy is. Dat omvat 0, "" en false. ?? vervangt alleen bij null en undefined. Dus 0 || 10 is 10, terwijl 0 ?? 10 0 is. Zo wordt een batchgrootte van nul ongemerkt tien.

3. Een catch-blok ontvangt unknown

Er is geen except ValueError:. Eén catch-blok krijgt alles. Omdat JavaScript toestaat een string, een getal of null te gooien, typeert TypeScript de opgevangen waarde als unknown, het type “kan letterlijk alles zijn” onder strict. De companion zet strict aan, en nieuwe projecten zouden dat meestal moeten doen. Om de fout te inspecteren, vernauw je eerst de waarde:

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

4. Promises starten meteen

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

Node beheert de runtime-planning. Het houdt het proces in leven zolang er nog actieve handles of requests zijn, zoals timers en sockets. Een gewone openstaande promise houdt Node niet in leven. Je verpakt het programma niet in asyncio.run. In een ES-module kun je await op het hoogste niveau gebruiken wanneer het opstarten op een async operatie moet wachten.

5. JavaScript heeft één gewoon numeriek type

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

Houd 64-bit-ID’s als strings op servicegrenzen. Een Postgres-bigint omzetten naar een JavaScript-number kan afronden. Voor grotere exacte gehele getallen biedt JavaScript het aparte type BigInt, dat zich niet met gewone getallen laat mengen.

6. Gebruik Map als je een dictionary in Python-stijl nodig hebt

In JavaScript maakt {} een object. Objecten stellen meestal records met benoemde velden voor:

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

Een object is geen schone sleutel-waardetabel zoals een Python-dict. Het erft sommige namen van JavaScript zelf. Dat kan een verrassend resultaat geven:

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 kiest, roep dan Object.hasOwn aan voordat je het leest. Als je een algemene dictionary nodig hebt, gebruik dan Map. Map lijkt meer op de dict van Python: een sleutel bestaat alleen als je code hem toevoegt.

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

7. Neem de extensie op in relatieve imports

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

Voor een relatieve import eist Node de exacte bestandsnaam. Het raadt niet of ./env ./env.ts of ./env.js betekent:

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

Imports uit geïnstalleerde pakketten of workspace-pakketten gebruiken nog steeds de pakketnaam, zonder bestandsextensie:

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

Zod: declareer het schema, leid het type af

In pydantic declareer je een klasse en krijg je een validator. In Zod declareer je een validator en leid je het type ervan af. Dezelfde ene bron van waarheid, in de tegenovergestelde richting.

De figuur toont dezelfde PORT-instelling in beide talen, en wat er van elke declaratie overblijft terwijl de code draait.

Twee banden vergelijken één PORT-instelling. In Python declareert class Env(BaseSettings) PORT: int = 8080; mypy controleert tegen de annotatie, de klasse bewaart die tijdens runtime en pydantic bouwt de validator ervan. In TypeScript declareert EnvSchema PORT met z.coerce.number(), en z.infer bouwt er type Env van tijdens de typecontrole. Terwijl de code draait, controleert EnvSchema process.env, en type Env bestaat dan niet meer, omdat types worden verwijderd voordat de code draait.Twee banden vergelijken één PORT-instelling. In Python declareert class Env(BaseSettings) PORT: int = 8080; mypy controleert tegen de annotatie, de klasse bewaart die tijdens runtime en pydantic bouwt de validator ervan. In TypeScript declareert EnvSchema PORT met z.coerce.number(), en z.infer bouwt er type Env van tijdens de typecontrole. Terwijl de code draait, controleert EnvSchema process.env, en type Env bestaat dan niet meer, omdat types worden verwijderd voordat de code draait.

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 statisch type terug uit het runtime-schema. z.coerce.number() vangt op dat elke gedefinieerde waarde in process.env (de os.environ van Node) een string is. Het vervult dezelfde rol als de numerieke-instellingencoercie van pydantic, al verschillen de exacte strings die elk accepteert. Dit volgt het pydantic-settings-patroon en draait één keer bij het opstarten. Een ongeldige omgeving geeft dan een leesbare opstartfout in plaats van een TypeError in een handler.

Een kale z.url() accepteert localhost:8000. De URL-standaard behandelt alles vóór de eerste dubbele punt als het schema. Daardoor leest hij localhost: als een protocol met de naam “localhost” en accepteert hij de string. De waarde komt dan bij de HTTP-client en faalt met minder context. Schemavalidatie verplaatst fouten naar eerder, maar dwingt wel een ruim schema af als je dat hebt geschreven.

Zod 4 levert ook z.toJSONSchema, dus dit project heeft de dependency zod-to-json-schema uit oudere tutorials niet nodig. Dat is van belang zodra één schema drie afnemers moet voeden, waar de sectie “Eén tool, drie afnemers” hieronder over gaat.


De service

Drie gedeelde packages bovenaan: @agent/schemas, @agent/core en @agent/observability. Daaronder drie processen. De Hono-API ontvangt HTTP-requests van een client zoals curl en antwoordt met SSE-frames of JSON, roept een OpenAI-compatibele modelserver aan op /chat/completions, en voegt runs in en leest ze uit de Postgres-tabel runs. De worker claimt queued-rijen uit die tabel en schrijft resultaten weg. De MCP-server praat met een AI-client via JSON-RPC over stdin en stdout en heeft geen HTTP-route en geen Postgres-verbinding.Drie gedeelde packages bovenaan: @agent/schemas, @agent/core en @agent/observability. Daaronder drie processen. De Hono-API ontvangt HTTP-requests van een client zoals curl en antwoordt met SSE-frames of JSON, roept een OpenAI-compatibele modelserver aan op /chat/completions, en voegt runs in en leest ze uit de Postgres-tabel runs. De worker claimt queued-rijen uit die tabel en schrijft resultaten weg. De MCP-server praat met een AI-client via JSON-RPC over stdin en stdout en heeft geen HTTP-route en geen Postgres-verbinding.

De bovenste rij is gedeelde code in packages/. Daaronder is elke app in apps/ een apart proces. Alleen de API en de worker raken Postgres, en de MCP-server praat met zijn client via stdin en stdout.

De demoservice bepaalt de grootte van LLM-deployments. Eén tool zoekt de architectuurconstanten van een model op. De andere schat het geheugenverbruik van de KV cache: het GPU-geheugen dat de attention keys en values van lopende requests vasthoudt. Beide tools zijn bewust saaie rekenkunde. Ze hebben geen netwerk nodig en geven elke keer hetzelfde antwoord. Daardoor is de service testbaar zonder API-sleutel. De schatter van de KV cache wordt ook gepubliceerd via het Model Context Protocol (MCP), 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 pakketten krijgen een scoped naam zoals @agent/core, waarbij het voorvoegsel @agent/ een naamgevingsconventie is en geen taalfunctie. Elk pakket declareert zijn publieke ingang in package.json. Die pakketgrens hangt niet af van het commando dat de applicatie start.

Deze private workspace laat die ingangen naar .ts-bron wijzen, omdat elke afnemer deel uitmaakt van dezelfde repository. Publieke npm-pakketten publiceren normaal JavaScript plus .d.ts-typedeclaraties, zodat gewone Node-afnemers de TypeScript-runner of de buildconfiguratie van de pakketauteur niet nodig hebben.


Schrijf de toolloop één keer met de hand

Eén runAgent-stap. Hij roept het model aan, leest de stream en levert per delta een text-event. Zonder tool calls levert hij done met stopReason stop. Anders doorloopt elke call lookup, JSON.parse, Zod-parse en execute; een mislukte controle of een ToolError wordt een tool result met ok false en fouttekst, en elke andere exception verlaat runAgent. De loop levert tool_call en tool_result, voegt de berichten toe en start de volgende stap, en na maxSteps stappen levert hij done met stopReason max_steps.Eén runAgent-stap. Hij roept het model aan, leest de stream en levert per delta een text-event. Zonder tool calls levert hij done met stopReason stop. Anders doorloopt elke call lookup, JSON.parse, Zod-parse en execute; een mislukte controle of een ToolError wordt een tool result met ok false en fouttekst, en elke andere exception verlaat runAgent. De loop levert tool_call en tool_result, voegt de berichten toe en start de volgende stap, en na maxSteps stappen levert hij done met stopReason max_steps.

De linkerkolom is één modelstap. De rechterkolom is wat de loop levert: op een exception na die de loop verlaat, zijn die events alles wat de HTTP-route en de tests zien.

Frameworks voor agents met tool calling verpakken dezelfde basisloop:

  1. Roep het model aan met tooldefinities.
  2. Valideer en voer de gevraagde tools uit.
  3. Voeg de resultaten toe aan de berichten.
  4. Roep het model opnieuw aan.

Als je de loop één keer schrijft, worden de verborgen faalpaden zichtbaar.

De loop is een async function*, een async generator, precies 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 verzamelt de tekst. Nadat de stream is afgelopen, roept de route eenmalig storage.createRun aan met de eindtekst. Tests roepen de loop apart aan en verzamelen de events in een array. Een SSE-frame is één brok van een langlopend HTTP-antwoord. De sectie hieronder legt het formaat uit.

De loop heeft twee normale uitgangen. Een antwoord zonder tool calls levert done op met stopReason: "stop". Als het model tot maxSteps tools blijft aanvragen, voltooit de generator toch en levert done op met stopReason: "max_steps". Behandel dat resultaat als voltooid maar afgekapt: de verzamelde tekst kan onvolledig zijn.

Elke waarde die de loop oplevert is een AgentEvent, een tagged union. Ingekort uit packages/schemas/src/events.ts:

export const AgentEventSchema = z.discriminatedUnion("type", [
    z.object({ type: z.literal("text"), delta: z.string() }),
    z.object({
        type: z.literal("tool_call"),
        callId: z.string(),
        toolName: z.string(),
        args: z.unknown(),
    }),
    z.object({
        type: z.literal("tool_result"),
        callId: z.string(),
        ok: z.boolean(),
        result: z.unknown() /* ... */,
    }),
    z.object({
        type: z.literal("done"),
        stopReason: z.enum(["stop", "max_steps"]),
        steps: z.number(),
        text: z.string(),
    }),
    z.object({ type: z.literal("error"), message: z.string() }),
]);

export type AgentEvent = z.infer<typeof AgentEventSchema>;

z.discriminatedUnion kiest de variant via het veld type, zoals een match op getagde dataclasses. Binnen if (event.type === "text") weet de compiler dat event.delta bestaat. Een variant toevoegen laat een onvolledige switch niet automatisch mislukken. Voor een compilerfout bij een ontbrekende variant controleer je de overgebleven waarde tegen never in een default-tak. Een lintregel voor volledige gevalsafhandeling kan ontbrekende gevallen ook melden. De HTTP-route en de AI SDK-versie hieronder gebruiken beide de vernauwde eventtypen.

De loop-body streamt tekst-events en zet tool calls uit fragmenten in elkaar. 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 hem in het assistentbericht voor de volgende stap of in het uiteindelijke done-event. Het is niet de accumulator op routeniveau die later wordt opgeslagen.

De partial-map is het deel dat frameworks verbergen. De SDK geeft stringfragmenten van de functieargumenten door, en die kunnen de geserialiseerde JSON op willekeurige posities splitsen. Meerdere parallelle calls kunnen ook door elkaar lopen. De repository heeft een test die {"model":"llama-3.1-8b",...} over vier chunks verdeelt.

De figuur volgt die vier chunks naar de map-entry voor index 0.

Vier gestreamde chunks, allemaal met index 0. De eerste bevat id c1 en naam estimate_kv_cache; de volgende drie bevatten stukken van de argumentenstring, en de eerste splitsing valt midden in de waarde llama-3.1-8b. De map-entry voegt de stukken samen, en pas nadat de stream is afgelopen maakt JSON.parse van de string model llama-3.1-8b, seqLen 4096, batchSize 1.Vier gestreamde chunks, allemaal met index 0. De eerste bevat id c1 en naam estimate_kv_cache; de volgende drie bevatten stukken van de argumentenstring, en de eerste splitsing valt midden in de waarde llama-3.1-8b. De map-entry voegt de stukken samen, en pas nadat de stream is afgelopen maakt JSON.parse van de string model llama-3.1-8b, seqLen 4096, batchSize 1.

Het tweede wat je zelf het best schrijft, is wat er gebeurt als 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 missen, kan JSON.parse de argumenten afwijzen of kan Zod hun vorm afwijzen. Elke fout wordt een bericht dat het model leest. Tijdens de uitvoering wordt ook een verwachte ToolError een tool result, zodat het model zijn call kan corrigeren. Een onverwachte exception loopt door naar het HTTP-foutpad in plaats van als domeinfout te worden gepresenteerd. z.prettifyError zet Zods issue-boom om in een bericht waar het model iets mee kan, in plaats van een stack trace.

strict: true op een OpenAI-functiedefinitie vraagt de provider de decoding tot het schema te beperken. Het heeft niets te maken met de strict-vlag van TypeScript in tsconfig. Het lijkt op vLLM’s guided decoding, al verschillen de ondersteunde schema’s en de details van de afdwinging. Het neemt één faalmodus weg, maar een zelf gehost endpoint kan de vlag negeren. De argumenten moeten bovendien JSON.parse doorstaan.

De loop roept /chat/completions aan, omdat de companion op OpenAI-compatibele servers mikt. vLLM, SGLang en Ollama documenteren dat endpoint, dus OPENAI_BASE_URL kan dezelfde client op elk ervan richten. Hun dekking van de Responses API verschilt en verandert per release. Als je beide kanten beheert, controleer dan de actuele compatibiliteitspagina van de server voordat je tussen de twee API’s kiest.


Stap daarna over op de AI SDK, en weet wat je inruilt

De companion implementeert dezelfde agent ook met de Vercel AI SDK, zodat je beide kunt vergelijken. Beide versies leveren AgentEvent-waarden, maar hun gedrag bij terminale fouten verschilt momenteel. De optionele SDK-adapter levert een error-event met String(part.error) en keert terug zonder done. De huidige HTTP-route stuurt dat detail door en slaat de run op als succeeded, omdat de generator zonder exception eindigt. Behandel error of een stream die zonder done sluit als een mislukte run voordat je de standaardloop door deze adapter vervangt. Schoon foutmeldingen voor clients op en test de opgeslagen status samen met het terminale event.

De companion gebruikt AI SDK 7. De implementatie met het framework 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 haalt vijf stukken applicatiecode weg:

  • de fragment-accumulator
  • JSON.parse en het bijbehorende foutpad
  • de aanroep die Zod over de geparseerde argumenten laat lopen
  • providerspecifieke berichtopbouw
  • de stapteller

stopWhen accepteert meerdere voorwaarden, waaronder een staplimiet of een specifieke tool call. De for await-loop verandert niet als het stopbeleid verandert.

Wat je opgeeft is directe controle over validatiefouten. De met de hand geschreven loop bepaalt wat het model ziet na een afgewezen call. De companion zet de optie repairToolCall van de SDK niet, dus gebruikt hij de standaard: als er meer dan één stap is toegestaan, stuurt AI SDK 7 de mislukte call in de volgende stap terug naar het model. Geef een repairToolCall-callback mee om de call zelf te herstellen, bijvoorbeeld met een tweede modelaanroep.

De ruil werkt ook andersom. In de SDK-versie blijft een providerwissel beperkt tot de provideradapter. Je hebt nog steeds het bijbehorende providerpakket, credentials, configuratie en integratietests nodig. In de ruwe loop is de providerspecifieke afhandeling van requests en streams jouw code om te wijzigen.

Begin voor een nieuwe service met de SDK, tenzij je die faalpaden moet beheersen. De met de hand geschreven versie geeft je een kleine implementatie om te inspecteren en met die keuze te vergelijken.


Streaming over HTTP: Hono en SSE

Hono-routes lezen als FastAPI-routes. De enige toevoeging is zValidator, dat doet wat FastAPI gratis krijgt uit de typeannotaties op de signatuur van een handler. De c in de handler hieronder is de requestcontext van Hono, het object dat FastAPI over je parameters verdeelt. deps is een bundel dependencies waarmee de app wordt opgebouwd in plaats van ze rechtstreeks te importeren. runAgent is er een van, en de testsectie laat zien wat dat oplevert.

Ingekort 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 = "";
        let done: Extract<AgentEvent, { type: "done" }> | undefined;
        try {
            // The full route wraps this loop in a `withSpan` tracing span.
            for await (const event of deps.runAgent({
                message: body.message,
                maxSteps: body.maxSteps,
            })) {
                if (event.type === "text") text += event.delta;
                if (event.type === "done") done = event;

                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:
                done?.stopReason === "max_steps" ? "truncated" : "succeeded",
            input: { message: body.message },
            output: { text },
        });
    });
});

De route houdt het laatste done-event vast. Een loop die bij max_steps stopt, voltooit normaal, dus de route slaat hem op als truncated, niet als succeeded.

zValidator valideert de body en geeft c.req.valid("json") het type dat het schema voortbrengt. Sla het over en de body is getypeerd als any, de uitstapoptie van TypeScript, waarbij elke property-toegang compileert en niets wordt gecontroleerd. Dat schakelt het voordeel van typeveiligheid van het schema uit.

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

Bij de standaardloop verandert een exception tijdens de stream de HTTP-foutmelding. 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 fout, stuurt een constant foutevent naar de client en keert terug. Daardoor wordt storage.createRun overgeslagen: een fout die de generator gooit, laat geen rij achter. Deze catch draait niet wanneer de optionele SDK-adapter een error-event levert en terugkeert. Schrijf voor debugging of evals een failed-rij met de gedeeltelijke tekst in het catch-blok als je mislukte runs wilt bewaren.

Het antwoord op POST /v1/chat: status 200 gaat mee met het eerste frame, daarna text-, tool_call- en tool_result-frames. Er volgen drie eindes: done met stopReason stop slaat de run op als succeeded; done met stopReason max_steps slaat hem op als truncated; een generator die een fout gooit stuurt een error-event met Agent run failed en slaat geen rij op. De client ziet in alle drie de gevallen status 200.Het antwoord op POST /v1/chat: status 200 gaat mee met het eerste frame, daarna text-, tool_call- en tool_result-frames. Er volgen drie eindes: done met stopReason stop slaat de run op als succeeded; done met stopReason max_steps slaat hem op als truncated; een generator die een fout gooit stuurt een error-event met Agent run failed en slaat geen rij op. De client ziet in alle drie de gevallen status 200.

De figuur volgt één request naar zijn drie eindes. De statuscode is in alle drie de gevallen gelijk, dus alleen het laatste frame onderscheidt ze.

Een test dekt dit pad. Een generator levert één tekstdelta op en gooit dan een fout. Het antwoord blijft 200 en het laatste frame is een error-event met het constante bericht Agent run failed. De logger bewaart de opgevangen fout voor diagnose aan de serverkant. Elke client die alleen de statuscode controleert, meldt succes bij een mislukte run.

app.ts maakt twee kleinere keuzes die uitleg verdienen. Het behandelt /healthz als liveness-endpoint, dus die route raakt Postgres bewust niet. Een liveness-fout tijdens een database-storing kan elke replica herstarten zonder de afhankelijkheid te herstellen. Voeg een aparte readiness-check toe wanneer de orchestrator het verkeer moet stoppen naar een instantie die Postgres niet kan bereiken. De foutpaden loggen de opgevangen fout maar geven een constante string terug. error.message in een responsbody teruggeven is de manier waarop connection strings in andermans browser terechtkomen.


Het Celery-achtige deel, zonder Celery

Lange jobs horen niet in een request handler. POST /v1/sweeps valideert een model, een sequentielengte en een lijst batchgroottes, voegt een queued-rij in en geeft 202 terug met de run-id. De worker claimt de rij, berekent de grootte van de KV cache voor elke batchgrootte en slaat het resultaat op. GET /v1/runs/:id leest het.

POST /v1/sweeps voegt een queued-rij in en geeft 202 terug. Een worker claimt die en zet hem op running, met lockedAt op het claimmoment; hij eindigt als succeeded of failed. Een tijdlijn eronder toont worker A die op minuut 5 nog steeds de sweep draait, wanneer de lock als verouderd geldt en worker B dezelfde rij opnieuw claimt.POST /v1/sweeps voegt een queued-rij in en geeft 202 terug. Een worker claimt die en zet hem op running, met lockedAt op het claimmoment; hij eindigt als succeeded of failed. Een tijdlijn eronder toont worker A die op minuut 5 nog steeds de sweep draait, wanneer de lock als verouderd geldt en worker B dezelfde rij opnieuw claimt.

De bovenste helft toont de statussen waar een sweep-rij doorheen gaat. De tijdlijn toont de reclaim van een verouderde lock, hieronder uitgelegd: na vijf minuten claimt een tweede worker een job die nog draait.

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

De claimquery 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()` adds a locking clause; `{ skipLocked: true }` adds SKIP LOCKED.
    .for("update", { skipLocked: true });

De rij is voor de transactie gelockt, en elke gelijktijdige worker die dezelfde query draait, slaat hem over in plaats van te blokkeren. Twee gelijktijdige claims krijgen dus niet dezelfde niet-verouderde rij. Een integratietest vuurt twee claims tegelijk af via Promise.all en controleert dat ze verschillende rijen teruggeven. De naïeve versie, SELECT ... LIMIT 1 gevolgd door UPDATE, faalt die test: beide transacties lezen dezelfde rij voordat een van beide schrijft, dus beide starten dezelfde job.

Twee sequencepanelen met lanes voor worker A, rij 7, rij 8 en worker B. Bij de naïeve claim selecteren beide workers rij 7 terwijl die nog queued is, daarna zetten ze hem allebei op running en draaien ze dezelfde job. Met FOR UPDATE SKIP LOCKED lockt worker A rij 7, slaat worker B die over en lockt rij 8, en elk draait een andere job.Twee sequencepanelen met lanes voor worker A, rij 7, rij 8 en worker B. Bij de naïeve claim selecteren beide workers rij 7 terwijl die nog queued is, daarna zetten ze hem allebei op running en draaien ze dezelfde job. Met FOR UPDATE SKIP LOCKED lockt worker A rij 7, slaat worker B die over en lockt rij 8, en elk draait een andere job.

Het bovenste paneel is de race die de test vangt; het onderste paneel is de claimquery hierboven.

Dit is at-least-once-uitvoering: een job kan meer dan eens draaien. De volledige query claimt ook een running-rij opnieuw als zijn lock ouder is dan vijf minuten, en niets verlengt die lease. Een actieve job die langer dan vijf minuten draait, kan dus twee keer worden geclaimd. Maak jobs idempotent. Voeg voor langlopend werk een lease-heartbeat toe, of zet de drempel voor verouderde locks boven de maximale looptijd.

Voeg BullMQ toe als je vertraagde jobs, terugkerende schema’s, prioriteiten, rate limits of een dashboard nodig hebt. De equivalente stap in Python is van een databasetabel naar Celery. Tot dan is Redis nog een service om te draaien en te monitoren.

De worker valideert wat hij uit jsonb leest opnieuw:

// 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 voert de worker ook een sweep-rij waarvan seqLen de string "lots" is. De worker laat de run falen en blijft pollen, in plaats van te crashen en dezelfde ongeldige rij eindeloos opnieuw te proberen.

CPU-werk legt een andere Node-beperking bloot. Een synchrone callback draait op de thread van de event loop en wordt niet onderbroken. Een for-loop die twee seconden rekent, blokkeert elk request, elke timer en elke liveness-check van dat proces twee seconden lang. Een strakke loop binnen een async def blokkeert asyncio op dezelfde manier. Beide runtimes vereisen dat je CPU-werk expliciet uitbesteedt.

await setTimeout(0) uit node:timers/promises (het voorvoegsel node: betekent standaardbibliotheek, dus node:timers is voor Node wat os voor Python is) is await asyncio.sleep(0). De sweep geeft na elke batchgrootte de controle af, zodat het worker-proces timers en andere callbacks kan afhandelen. De controle afgeven maakt CPU-werk niet parallel. Node worker_threads kunnen JavaScript parallel uitvoeren. Gebruik voor puur Python-CPU-werk onder de gebruikelijke CPython-build met GIL 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 staan, en haal ze van de event loop van de API af.


Eén tool, drie afnemers

EstimateKvCacheInput heeft drie afnemers:

  • De met de hand geschreven loop zet hem om met z.toJSONSchema.
  • De AI SDK ontvangt hem ongewijzigd.
  • De MCP-server publiceert zijn vorm.

Dat hergebruik 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 van belang voordat je een client verbindt. Ten eerste: een server die zo wordt gestart, gebruikt 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, beschadigt dan een bericht. De client verbreekt de verbinding met een parsefout die geen bestand noemt. Stuur in plaats daarvan alle diagnostiek naar stderr.

Ten tweede: een domeinfout moet isError: true teruggeven met een bericht. Het aanroepende model kan dan de call corrigeren, net zoals het dat kan na ongeldige tool-argumenten in de agent loop.

Je kunt de server aansturen met printf en een pipe, wat de moeite waard is om één keer te doen 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 de README van de companion gebruikt. Gebruik voor een echte client de SDK in plaats van JSON-RPC-berichten met de hand te onderhouden.


Een agent testen zonder API-sleutel

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

Agenttests hangen af van één beslissing: runAgent neemt een OpenAI-client als parameter in plaats van er zelf een te bouwen. De fake is een object met een chat.completions.create-methode die een gescripte async iterable teruggeeft:

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
        // cast through `unknown` first. This disables type checking for that
        // value, so keep it to test code.
    } as unknown as OpenAI;
}

De tests splitsen één JSON-argumentstring over chunks en verwerken twee tool calls in één antwoord. Ze dekken ook een ongeldige batchSize, misvormde JSON, onbekende toolnamen en een model dat tools blijft aanroepen tot maxSteps het stopt. Het testbestand draait ruim binnen een seconde, zonder netwerk en zonder sleutel.

Integratietests tegen Postgres gebruiken describe.skipIf(!process.env.DATABASE_URL), zodat pnpm test werkt op een verse clone zonder draaiende Postgres, en CI ze aanzet door de variabele mee te geven. De repository heeft 42 tests. Achtendertig daarvan draaien zonder Postgres of API-sleutel.


Log gestructureerde events met Pino

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

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

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


Trace applicatiewerk met handmatige spans

De companion gebruikt OpenTelemetry voor drie spans op applicatieniveau: agent.run, agent.tool en worker.sweep. Hij installeert geen automatische HTTP- of Postgres-instrumentatie. startTracing() in packages/observability/src/tracing.ts maakt een NodeSDK met een OTLP-traceexporter. Als OTEL_EXPORTER_OTLP_ENDPOINT ontbreekt, laat het tracing uit.

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

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

JavaScript heeft geen context manager-syntaxis in Python-stijl. Hier is de callback het blok dat een Python-context manager zou omsluiten. De volledige helper registreert ook exceptions en zet de spanstatus voordat hij opnieuw gooit.

Automatische HTTP- en databasespans zijn een aparte functie. Ze vereisen de bijbehorende instrumentatiepakketten en initialisatie voordat de geïnstrumenteerde modules laden. Voeg dat alleen toe wanneer die spans nuttig zijn, en volg dan de setup van de OpenTelemetry Node SDK voor de exacte pakketversies die je uitrolt.


Lever de monorepo uit in Docker

Gebruik de meegeleverde Dockerfile. De container start de API met de tsx-loader. Je hoeft geen TypeScript-runner te kiezen of aan te roepen wanneer je hem uitrolt.

De build gebruikt pnpm fetch, zodat gedownloade dependencies in de cache blijven tot het lockfile verandert. Daarna gebruikt hij pnpm deploy om de API en zijn productiedependencies naar een zelfstandige map te kopiëren. De runtime-stage draait als de niet-root-gebruiker node, en zijn CMD in exec-vorm laat de API SIGTERM rechtstreeks ontvangen voor een nette shutdown.

Waarom de Dockerfile tsx laadt

Node 24 kan een beperkte subset van TypeScript draaien door typeannotaties te verwijderen. Het controleert de types van de code niet en voert de transformaties niet uit die een volledige TypeScript-runner ondersteunt. De package-scripts van de repository verbergen dat detail. pnpm check voert de aparte statische controle uit.

De container laat een andere beperking zien. pnpm deploy kopieert de workspace-pakketten onder node_modules, en Node weigert daar bewust TypeScript te strippen (Node TypeScript-documentatie). Zonder loader faalt de container bij het opstarten met ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.

De Dockerfile laadt tsx, dat die .ts-bestanden verwerkt voordat Node ze uitvoert. Een team dat alleen .js-bestanden in zijn runtime-image wil, kan in plaats daarvan een compilatiestap toevoegen. Dat is een alternatief productieontwerp, geen extra stap die nodig is om deze companion te draaien.


Een pad van drie weken

Ervaren Python-engineers kunnen materiaal overslaan dat variabelen en loops uitlegt. Deze volgorde richt zich op de onderdelen die van Python verschillen. Doe in elke rij de bouwtaak; het leesmateriaal ondersteunt die.

WeekLeesBouw
1javascript.info: alleen modules, promises en objecten. Houd MDN’s JS Guide als naslag.Herschrijf één Python-CLI in TypeScript. Voeg een package.json-script toe. Draai het script en pnpm typecheck.
1-2Lees TypeScripts tsconfig-referentie, de gratis Total TypeScript-tutorials en de Zod-docs.Bouw een met Zod gevalideerde configmodule en één tagged union. Na controle van de tag weet de compiler welke variant het blok bevat.
2Lees de documentatie van Hono, Drizzle, Vitest en Biome.Bouw een streaming proxy naar een OpenAI-compatibel endpoint met een log op basis van Drizzle.
3Lees de documentatie van de AI SDK en de MCP TypeScript SDK.Bouw een agent met tool calling. Bouw daarna een MCP-server die een van zijn tools beschikbaar maakt.

Begin met de gratis Total TypeScript-tutorials. Betaal pas voor het gevorderde materiaal wanneer je met generics en conditional types op bibliotheekniveau werkt. Sla elke cursus “intro tot JavaScript” over, en sla alles over wat naar React neigt, tenzij het product het vereist.

Voor een overzicht van productieconventies is goldbergyoni/nodebestpractices een brede, door de community onderhouden checklist. Controleer advies dat het runtimegedrag of de beveiliging raakt aan de actuele Node-documentatie.


Voordat je de service aanpast

De offline tests oefenen de besturingsstroom van de service uit met gescripte modelantwoorden. Ze stellen niet vast hoe betrouwbaar een live model jouw taken voltooit. Houd dat onderscheid in gedachten wanneer je de demotools door echte vervangt.

Valideer elke waarde die van buiten binnenkomt: de omgeving, de HTTP-body, de tool-argumenten van het model en rijen die uit jsonb worden gelezen.

WaardeGecontroleerd doorBij een fout
process.envEnvSchema, in loadEnv() bij het opstartenopstartfout; het proces stopt
HTTP-bodyChatRequestSchema, via zValidator400; de handler draait nooit
Tool-argumententool.parse, na JSON.parsetool-fout die naar het model gaat
runs.inputSweepRequestSchema, in de worker bij elke claimrun gemarkeerd als failed; de worker blijft pollen

Leg daarna het afrondingsbeleid vast: bepaal of je applicatie truncated-runs accepteert, opnieuw probeert of laat beoordelen, en test het event en de opgeslagen status samen. Als jobs langer kunnen draaien dan de drempel voor verouderde locks, voeg dan een lease-heartbeat toe voordat je side effects toevoegt.

Zodra die gedragingen expliciet zijn, voeg je de failed-rij toe die in de streamingsectie is beschreven, en gebruik je mislukte en afgekapte runs om een aparte evaluatiesuite voor agents te bouwen. Daar test je of taken worden voltooid; de offline suite blijft de snellere controle op kapot streaming-, validatie- en opslaggedrag.


Referenties

Demorepository

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

Runtime en taal

Tooling

  • pnpm en Installatie van pnpm - package manager, workspaces en setup
  • Biome - lint, format en het sorteren van imports in één binary
  • Vitest - testrunner die geen transformatieconfiguratie nodig heeft
  • Total TypeScript - gratis tutorials plus een betaald traject voor geavanceerde types

Libraries

  • Zod - schemavalidatie en typeafleiding. Versie 4 bevat z.toJSONSchema.
  • Hono - HTTP-framework op webstandaarden
  • Drizzle ORM - SQL-first TypeScript-ORM met drizzle-kit-migraties
  • SELECT-documentatie van PostgreSQL - de lockclausule FOR UPDATE ... SKIP LOCKED
  • BullMQ - queue op Redis-basis voor als de databasetabel niet genoeg is

AI en agents

Conventies