TypeScript for Python ML engineers: build an agent service

This is a fast onboarding guide for experienced Python engineers who need to ship AI services in TypeScript and Node. It is written for ML engineers, data scientists, and backend developers who do not need a beginner JavaScript course.

I did that onboarding myself over the past few months after working in Python and Java. Most guides I found started with basic programming or front-end DOM work. This article starts from Python service concepts. By the end, you can map a Python service stack to TypeScript and recognize the Python habits that cause JavaScript bugs. The running example follows a streaming agent service from schema to deployment.

Summary: Install Node 24 and pnpm. Then use the repository’s pnpm commands. Run pnpm demo for the offline example, pnpm dev:api and pnpm dev:worker for development, and pnpm check before a commit. You do not need to run node, tsx, or the TypeScript checker yourself. The package scripts do that.

The service uses Zod, Hono, Drizzle, Vitest, and Biome. They cover much of the same ground as pydantic, FastAPI, SQLAlchemy, pytest, and Ruff. Keep heavy numerics in Python. Use this TypeScript service for orchestration, HTTP, and streaming.

Everything here is a file in slavadubrov/typescript-agent-service, the companion repository published with this article. It contains an HTTP API, two versions of the same agent loop, Postgres run history, a worker, and an MCP server. pnpm install && pnpm demo runs the offline HTTP/SSE agent path and the worker’s sweep computation with no API key.

I’m covering backend and AI work only. No React. There is no browser bundler either.


Run the companion first

Install Node 24 and pnpm using the official pnpm installation instructions. Then clone the companion repository and run:

pnpm install
pnpm demo
pnpm check

pnpm demo exercises the HTTP handler, agent loop, and SSE stream with a scripted model. It also calls the worker’s runSweep computation. It does not start the worker process or its database queue. The demo needs no API key, database, or Docker. pnpm check runs the type checker, linter, formatter check, and tests.

To run the real API and 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

Those are the commands I use in the rest of this article. The repository puts the lower-level Node and TypeScript commands behind named pnpm scripts, much as a Python project might put uv run commands behind make targets. Do not mix npm install into this pnpm repository. Use pnpm install so pnpm-lock.yaml remains the single lockfile.


What Node, npm, pnpm, TypeScript, and tsx do

The similar names hide separate jobs:

For this repository, run the pnpm scripts. Node is the runtime inside those scripts, and the supplied Dockerfile handles production.


The stack, mapped from Python

Two columns map each concern in a Python AI service to its TypeScript replacement. The rows cover project metadata, packages, validation, HTTP, SQL, queues, tests, linting, and type checks. Three rows are not drop-in swaps.

Most of the map is boring, which is the good news. Three rows are not:

ConcernPythonTypeScriptWhy it is not a swap
ValidationpydanticzodThe schema is the source. The type is generated from it, not the reverse
Type checkmypyTypeScript (pnpm typecheck)Both check source without validating data that arrives at runtime
Job queuecelery + Redisbullmq (Redis-backed queue) or SQLPostgres can implement an at-least-once queue. You may not need a broker

The companion uses four libraries worth explaining.

Hono for the HTTP layer

Express and Fastify are Node-focused alternatives. Hono uses the Web-standard Request and Response APIs and provides adapters for Node and serverless runtimes. That portability is useful for this small streaming API, so I chose Hono.

Drizzle for SQL

Drizzle keeps the schema in TypeScript and does not require a client-generation step. It also makes raw SQL available when the query builder cannot express a Postgres clause cleanly. I would choose Prisma instead when its generated client and surrounding tooling fit the team better.

Biome for linting and formatting

Biome handles linting, formatting, and import sorting with one binary and one configuration file. Keep ESLint when the project depends on custom rules that Biome does not provide.

Vitest for tests

Vitest runs the companion’s .ts tests without separate transform configuration.


Read the TypeScript syntax used below

Keep this table beside the service examples for reference.

TypeScriptPython / note
(x) => expressionanonymous function with an expression body, similar to lambda x: expression
(x) => { statements }anonymous function with a statement body
async (x) => { statements }async anonymous function
const { model, seqLen } = requestextract the model and seqLen properties from request
const [first] = xsfirst = xs[0]. It yields undefined, not IndexError, when empty
{ type: "error", message }{"type": "error", "message": message}. A bare name becomes that field
`text ${x}`f-string
cond ? a : ba if cond else b
const / letBoth bind a name. const forbids rebinding, while let allows it
exportmakes a name importable
switch / casematch, except cases fall through unless they end in break or return
for awaititerating an async generator
i++increments and returns the old value
/^https?$/a regex literal, no re.compile needed
T[], Map<K, V>list[T], dict[K, V]

Use const unless the binding must change. Use let for a counter, accumulator, or another binding you will reassign.

A short enum translation

For string-valued states, this repository uses an object plus an inferred string-union type:

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

The object provides Status.Queued while the program runs. The type line allows only "queued" or "running" during type checking. Together they fill the two roles of this Python declaration:

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

You only need to recognize the pattern. as const keeps the object’s values as the exact strings rather than widening them to any string.


The seven semantic differences that cost time

The syntax table gets you through the examples. These semantic differences are where Python habits cause bugs.

1. Empty arrays and objects are truthy

Python’s “empty container is falsy” is the habit that transfers worst. if (results) is true for an empty array. Write if (results.length).

2. null and undefined are different

null usually marks a deliberate absence. undefined usually means that a value is missing or unassigned, although code can assign it explicitly. Library code returns undefined constantly. The difference bites when you write a default. || substitutes the right side whenever the left side is falsy. That includes 0, "", and false. ?? substitutes only for null and undefined. Thus, 0 || 10 is 10, while 0 ?? 10 is 0. That difference is how a batch size of zero silently becomes ten.

3. A catch block receives unknown

There is no except ValueError:. One catch block gets everything. Because JavaScript lets you throw a string, a number, or null, TypeScript types the caught value as unknown, its “could be literally anything” type under strict. The companion enables strict, and new projects generally should. To inspect the error, narrow the value first:

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 start immediately

Calling an async function starts executing its body and returns a promise. A Python coroutine object does nothing until you await or schedule it. Promise.all is close to asyncio.gather. Promise.allSettled is close to gather(..., return_exceptions=True), except each result is wrapped as { status, value } or { status, reason }.

Node owns runtime scheduling. It keeps the process alive while active handles or requests, such as timers and sockets, still exist. An ordinary pending promise alone does not keep Node alive. You do not wrap the program in asyncio.run. In an ES module, you can use await at the top level when startup must wait for an async operation.

5. JavaScript has one ordinary numeric type

JavaScript’s number type stores values as 64-bit floating-point numbers, roughly the same as Python’s float. The technical standard for this format is called IEEE 754. Decimal values are approximate, so 0.1 + 0.2 is not exactly 0.3, and integers remain exact only up to 2**53 - 1, or 9,007,199,254,740,991.

Keep 64-bit IDs as strings at service boundaries. Converting a Postgres bigint to a JavaScript number can round it. For larger exact integers, JavaScript provides the separate BigInt type, which does not mix with ordinary numbers.

6. Use Map when you need a Python-style dictionary

In JavaScript, {} creates an object. Objects usually represent records with named fields:

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

An object is not a clean key-value table like a Python dict. It inherits some names from JavaScript itself. This can produce a surprising result:

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

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

If an external string selects a field from an object, call Object.hasOwn before reading it. If you need a general-purpose dictionary, use Map. Map is closer to Python’s dict: a key exists only when your code adds it.

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

7. Include the extension in relative imports

A JavaScript source file that shares code with other files is called a module. This project uses the modern module format, ES modules, usually shortened to ESM. ES stands for ECMAScript, the formal name of the JavaScript language. In practice, ESM is the import and export syntax used throughout this project.

For a relative import, Node requires the exact filename. It will not guess whether ./env means ./env.ts or ./env.js:

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

Imports from installed or workspace packages still use the package name, with no file extension:

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

Zod is pydantic with the arrow reversed

In pydantic you declare a class and get a validator. In Zod you declare a validator and derive the type from it. Same single source of truth, opposite direction.

From 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> reads a static type back out of the runtime schema. z.coerce.number() handles the fact that each defined value in process.env (Node’s os.environ) is a string. It serves the same role as pydantic’s numeric settings coercion, though the exact strings each accepts differ. This follows the pydantic-settings pattern and runs once at startup. An invalid environment then produces a readable startup error instead of a TypeError inside a handler.

A bare z.url() accepts localhost:8000. The URL standard treats everything before the first colon as the scheme. It therefore reads localhost: as a protocol named “localhost” and accepts the string. The value then reaches the HTTP client and fails with less context. Schema validation moves failures earlier, but it will enforce a permissive schema if that is what you wrote.

Zod 4 also ships z.toJSONSchema, so this project does not need the zod-to-json-schema dependency common in older tutorials. That matters once one schema has to feed three consumers, which is what the “One tool, three consumers” section below is about.


The service

A pnpm workspace has packages and apps. The packages contain schemas, agent code, and observability. One schema feeds the hand-written loop, the Vercel AI SDK, and the MCP server. The apps contain a Hono API, a worker, and an MCP server. The API and worker share one Postgres runs table.

The demo service sizes LLM deployments. One tool looks up a model’s architecture constants. The other estimates its KV-cache footprint: the GPU memory used to hold attention keys and values for in-flight requests. Both tools are deliberately dull arithmetic. They need no network and give the same answer every time. That makes the service testable without an API key. The KV-cache estimator is also published over the Model Context Protocol (MCP), so other AI clients can call it.

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 the file that declares the workspace. Internal packages get a scoped name like @agent/core, where the @agent/ prefix is a naming convention, not a language feature. Each package declares its public entry point in package.json. That package boundary does not depend on which command starts the application.

This private workspace points those entries at .ts source because every consumer is part of the same repository. Public npm packages normally publish JavaScript plus .d.ts type declarations so ordinary Node consumers do not need the package author’s TypeScript runner or build setup.


Write the tool loop by hand, once

One step of an agent loop. The model streams chunks. The loop assembles tool calls by index, parses their JSON, and validates them with Zod. Valid calls run the tool. Invalid calls produce an error that the model reads before the loop repeats. The loop yields typed AgentEvent values. The HTTP route emits one SSE frame per event, accumulates text, and calls storage after the stream. Tests consume the generator separately.

Tool-calling agent frameworks wrap the same basic loop:

  1. Call the model with tool definitions.
  2. Validate and run the requested tools.
  3. Append the results to the messages.
  4. Call the model again.

Write this loop once. Framework behavior then becomes an engineering choice you can defend.

The loop is an async function*, an async generator, the exact shape of Python’s async def with yield. The HTTP route iterates that generator, turns each event into a Server-Sent Events frame, and accumulates the text. After the stream ends, the route calls storage.createRun once with the final text. Tests invoke the loop separately and collect its events into an array. An SSE frame is one chunk of a long-lived HTTP response. The section below explains the format.

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

The loop-local text variable holds one model step. The loop uses it in the assistant message for the next step or in the final done event. It is not the route-level accumulator that is later persisted.

The partial map is the part frameworks hide. The SDK exposes string fragments of the function arguments, and they may split the serialized JSON at arbitrary positions. Several parallel calls can also interleave. The repo has a test that splits {"model":"llama-3.1-8b",...} across four chunks.

The second thing worth writing yourself is what happens when validation fails:

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

Before execution, the lookup can miss the tool, JSON.parse can reject the arguments, or Zod can reject their shape. Each failure becomes a message the model reads. During execution, an expected ToolError also becomes a tool result so the model can correct its call. An unexpected exception propagates to the HTTP error path instead of being presented as a domain failure. z.prettifyError turns Zod’s issue tree into a message the model can act on instead of a stack trace.

strict: true on an OpenAI function definition asks the provider to constrain decoding to the schema. It has no relation to TypeScript’s strict tsconfig flag. This resembles vLLM’s guided decoding, although supported schemas and enforcement details differ. It removes one failure mode, but a self-hosted endpoint may ignore the flag. The arguments must also survive JSON.parse.

The loop calls /chat/completions because the companion targets OpenAI-compatible servers. vLLM, SGLang, and Ollama document that endpoint, so OPENAI_BASE_URL can point the same client at any of them. Their Responses API coverage differs and changes by release. If you control both sides, check the server’s current compatibility page before choosing between the two APIs.


Then switch to the AI SDK, and know what you traded

For later projects, I would use the Vercel AI SDK. The companion implements the same agent twice so the trade is visible. Both versions emit the same AgentEvent stream, so the HTTP layer cannot tell them apart.

The companion pins AI SDK 7.0.42 in packages/agent-core/package.json. Its framework implementation lives 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;
        // ...
    }
}

The SDK removes five pieces of application code:

stopWhen accepts several conditions, including a step limit or a specific tool call. The for await loop does not change when the stop policy changes.

What you give up is direct control over validation failure. The hand-written loop decides what the model sees after a rejected call. In the SDK version, you configure that behavior through repairToolCall. The trade runs the other way too. In the SDK version, a provider change is localized to the provider adapter. It still requires the matching provider package, credentials, configuration, and integration tests. In the raw loop, provider-specific request and stream handling are your code to change.

I hand-write the loop on the first project and use the SDK on later ones. You pay for that lesson once. The alternative is reading a framework’s internals for the first time while it fails in production.


Streaming over HTTP: Hono and SSE

Hono routes read like FastAPI routes. The one addition is zValidator, which does the job FastAPI gets for free from the type annotations on a handler’s signature. The c in the handler below is Hono’s request context, the object FastAPI splits across your parameters. deps is a bag of dependencies the app is constructed with instead of importing them directly. runAgent is one of them, and the testing section shows what that buys you.

From 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 validates the body and gives c.req.valid("json") the type the schema produces. Skip it and the body is typed any, TypeScript’s opt-out, where every property access compiles and nothing is checked. That disables the schema’s type-safety benefit.

This route uses Server-Sent Events instead of WebSockets. The server keeps an HTTP response open while it writes event: <name> and data: <json> frames, then closes it after the final event. Traffic flows from server to client, which matches this agent stream. A WebSocket would add bidirectional messaging and a protocol upgrade that this route does not need.

A midstream failure changes HTTP error reporting. Once the first frame has been sent with status 200, the server cannot replace that response with a 500. The catch block logs the caught error, sends a constant error event to the client, and returns. That return matters: only a successfully completed stream reaches storage.createRun.

A test covers this path. A generator yields one text delta and then throws. The response remains 200, and its last frame is an error event with the constant message Agent run failed. The logger keeps the caught error for server-side diagnosis. Any client that only checks the status code reports success on a failed run.

app.ts makes two smaller decisions worth explaining. It treats /healthz as a liveness endpoint, so that route deliberately does not touch Postgres. A liveness failure during a database outage could restart every replica without repairing the dependency. Add a separate readiness check when the orchestrator must stop routing traffic to an instance that cannot reach Postgres. The error paths log the caught error but return a constant string. Echoing error.message into a response body is how connection strings end up in someone else’s browser.


The Celery-shaped part, without Celery

Long jobs do not belong in a request handler. The API inserts a row and returns 202. A worker claims the row.

There is no Redis here, and no BullMQ. PostgreSQL documents SKIP LOCKED for multiple consumers of a queue-like table. The clause gives this small service an at-least-once queue in one table. It is transactional with the rest of your writes, and it is one fewer service in docker-compose.yml.

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

The row is locked for the transaction, and any concurrent worker running the same query skips it instead of blocking. Two simultaneous claims therefore do not receive the same non-stale row. An integration test fires two claims at once through Promise.all and asserts they return different rows. The naive version, SELECT ... LIMIT 1 followed by UPDATE, fails that test: both transactions read the same row before either writes, so both start the same job.

This is at-least-once execution, not exactly-once execution. The full query also reclaims a running row when its lock is more than five minutes old, and the demo worker does not renew that lease. A live job that runs longer than five minutes can therefore be claimed twice. Make jobs idempotent. For long-running work, add a lease heartbeat or set the stale-lock threshold above the maximum runtime.

Add BullMQ when you need delayed jobs, repeatable schedules, priorities, rate limits, or a dashboard. I would make the same move from a database table to Celery in Python. Before that, Redis is one more service to run, monitor, and explain to whoever is on call.

The worker re-validates what it reads from jsonb:

// The row was validated on the way in, but it has been through a database.
// A stored row can outlive the schema version that accepted it.
// The job is a batch-size sweep. Zod's own `.parse` throws; the poll loop
// catches that and marks the run failed.
const input = SweepRequestSchema.parse(run.input);

The test suite also feeds the worker a row whose seqLen is a string. The worker fails the run and keeps polling, instead of crashing and retrying the same poison row forever.

CPU work exposes another Node constraint. A synchronous callback runs on the event-loop thread and is not preempted. A for loop grinding through arithmetic for two seconds blocks every request, timer, and liveness check on that process for two seconds. A tight loop inside an async def blocks asyncio in the same way. Both runtimes require you to offload CPU work explicitly.

await setTimeout(0) from node:timers/promises (the node: prefix means standard library, so node:timers is to Node what os is to Python) is await asyncio.sleep(0). The sweep yields after each batch size so the worker process can service timers and other callbacks. Yielding does not make CPU work parallel. Node worker_threads can execute JavaScript in parallel. For pure Python CPU work under the usual GIL-enabled CPython build, use a process pool rather than a thread pool. This service uses neither. Keep heavy numerics in Python where the supporting libraries already live, and move them off the API event loop.


One tool, three consumers

EstimateKvCacheInput has three consumers:

That reuse is why packages/schemas exists.

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

Two details matter before you connect a client. First, a server started this way uses its own stdin and stdout to communicate with the client. Each line is a JSON-RPC message. A stray console.log, the JavaScript equivalent of print, then corrupts a message. The client disconnects with a parse error that names no file. Send all diagnostics to stderr instead.

Second, a domain failure should return isError: true with a message. The calling model can then correct the call, just as it can after invalid tool arguments in the agent loop.

You can drive the server with printf and a pipe, which is worth doing once before you point a real client at it:

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

The probe matches the protocol version used by the companion README. For a real client, use the SDK rather than maintaining JSON-RPC messages by hand.


Testing an agent with no API key

Vitest fills pytest’s role, but the structure is different. describe groups related tests. it and test each define one test case. test.each is close to parametrize, beforeEach provides per-test setup, vi.fn() creates a mock function, and describe.skipIf conditionally skips a group.

Agent tests depend on one decision: runAgent takes an OpenAI client as a parameter instead of constructing one. The fake is an object with a chat.completions.create method returning a scripted async iterable:

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

The tests split one JSON argument string across chunks and handle two tool calls in one response. They also cover invalid batchSize, malformed JSON, unknown tool names, and a model that keeps calling tools until maxSteps stops it. The test file runs in well under a second, with no network and no key.

Integration tests against Postgres use describe.skipIf(!process.env.DATABASE_URL), so pnpm test works on a fresh clone with no Postgres running, and CI turns them on by supplying the variable. The repo has 40 tests. Thirty-six run without Postgres or an API key.


Log structured events with Pino

Pino fills the same role as structlog: one JSON object per line, child loggers with bound fields, and explicit redaction. The companion configures it in packages/observability/src/logger.ts:

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

Without redaction, log.info({ req }, "...") can copy an Authorization header into the log backend.


Trace application work with manual spans

The companion uses OpenTelemetry for three application-level spans: agent.run, agent.tool, and worker.sweep. It does not install automatic HTTP or Postgres instrumentation. startTracing() in packages/observability/src/tracing.ts creates a NodeSDK with an OTLP trace exporter. If OTEL_EXPORTER_OTLP_ENDPOINT is absent, it leaves tracing off.

The work itself is wrapped by withSpan() from the same file:

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

JavaScript has no Python-style context manager syntax. Here, the callback is the block that a Python context manager would surround. The full helper also records exceptions and sets the span status before rethrowing.

Automatic HTTP and database spans are a separate feature. They require the matching instrumentation packages and initialization before the instrumented modules load. Add that only when those spans are useful, then follow the OpenTelemetry Node SDK setup for the exact package versions you deploy.


Ship the monorepo in Docker

Use the supplied Dockerfile. The container starts the API with the tsx loader. You do not need to choose or invoke a TypeScript runner when you deploy it.

The build uses pnpm fetch so dependency downloads stay cached until the lockfile changes. It then uses pnpm deploy to copy the API and its production dependencies into a self-contained directory. The runtime stage runs as the non-root node user, and its exec-form CMD lets the API receive SIGTERM directly for graceful shutdown.

Why the Dockerfile loads tsx

Node 24 can run a limited TypeScript subset by stripping type annotations. It does not type-check the code or perform the transformations that a full TypeScript runner supports. The repository’s package scripts hide that detail. pnpm check performs the separate static check.

The container exposes another limit. pnpm deploy copies the workspace packages under node_modules, and Node deliberately refuses to strip TypeScript there (Node TypeScript documentation). The first version of the image crashed with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. It works. I was discouraged.

The Dockerfile fixes the problem by loading tsx, which handles those .ts files before Node executes them. A team that wants only .js files in its runtime image can add a compile step instead. That is an alternative production design, not an extra step required to run this companion.


A three-week path

Experienced Python engineers can skip material that teaches variables and loops. This sequence focuses on the parts that differ from Python. The build column is the point of each row. The reading supports it.

WeekReadBuild
1javascript.info: modules, promises, and objects only. Keep MDN’s JS Guide as a reference.Rewrite one Python CLI in TypeScript. Add a package.json script. Run the script and pnpm typecheck.
1-2Read TypeScript’s tsconfig reference, the free Total TypeScript tutorials, and the Zod docs.Build a Zod-validated config module and one tagged union. After checking the tag, the compiler knows which variant the block contains.
2Read the Hono, Drizzle, Vitest, and Biome documentation.Build a streaming proxy to an OpenAI-compatible endpoint with a Drizzle-backed log.
3Read the AI SDK and MCP TypeScript SDK documentation.Build a tool-calling agent. Then build an MCP server that exposes one of its tools.

Start with the free Total TypeScript tutorials. Pay for the advanced material only when working with library-grade generics and conditional types. Skip every “intro to JavaScript” course, and skip anything React-shaped unless the product requires it.

For a survey of production conventions, goldbergyoni/nodebestpractices is a broad community-maintained checklist. Verify advice that affects runtime behavior or security against the current Node documentation.


Trade-offs

Keep numerics in Python

Node works well for orchestration, HTTP serving, and streaming. Sustained CPU-bound math blocks its main event-loop thread. Keep vLLM and training code in Python unless a measured workload justifies moving them.

Validate every boundary at runtime

A TypeScript annotation does not check an HTTP body, environment variable, model-generated tool argument, or row read from jsonb. Each boundary needs a runtime schema.

Isolate SDK churn

AI SDK 6 replaced Experimental_Agent with ToolLoopAgent and renamed the agent setting system to instructions (AI SDK 6 migration guide). The companion calls streamText directly on AI SDK 7 and exposes its own AgentEvent stream. That boundary keeps the HTTP route unchanged when SDK code changes.

Skip the hand-written loop when the deadline matters more

Writing the loop once teaches you which behavior the SDK owns. If you need to ship first and have no reason to customize validation failures, start with the SDK.


Key takeaways

  1. Install Node 24 and pnpm. Then use the repository’s scripts: pnpm demo, pnpm dev:api, pnpm dev:worker, and pnpm check. The scripts hide the lower-level runtime and type-checker commands.
  2. Runtime validation is structurally necessary. Zod is this project’s chosen validator. Static types do not inspect HTTP bodies, environment variables, model output, or database rows. With Zod, declare a runtime schema and derive the TypeScript type with z.infer.
  3. The stack mostly maps cleanly: pnpm for uv, Hono for FastAPI, Drizzle for SQLAlchemy, Vitest for pytest, Biome for Ruff. Three rows are not swaps: validation, type checking, and the job queue.
  4. Write one agent loop by hand if you need to learn or customize the hidden paths: fragment accumulation, validation, and tool-error feedback.
  5. Make the loop an async generator. The HTTP route consumes its AgentEvent values, emits SSE frames, accumulates the text, and persists it after the stream. Tests consume the generator separately with no network or API key.
  6. Postgres can provide an at-least-once queue. Make handlers idempotent, and renew the lease or size it above the maximum runtime for long jobs. Add BullMQ when you need delays, priorities, or schedules.
  7. Use the supplied Dockerfile for production. It packages the selected app, loads TypeScript with tsx, runs as a non-root user, and forwards shutdown signals to the API process.

References

Demo repository

Runtime and language

Tooling

Libraries

AI and agents

Conventions