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:
- JavaScript is the language.
- Node.js is the runtime, roughly CPython for JavaScript. Install Node 24 for this project.
- The npm registry is the package index, roughly PyPI. The
npmcommand comes with Node and can install packages from that registry. - pnpm is the package manager this repository chose. It installs from the
npm registry, manages the monorepo workspace, and runs the commands declared
in
package.json. package.jsonis the project manifest, closest topyproject.toml. Itsscriptssection gives names such asdemo,check, anddev:apito longer commands.- TypeScript is JavaScript with static types. Its
tsccommand checks those types. The repository runs it throughpnpm checkorpnpm typecheck. - tsx runs
.tsfiles without a separate build. The development scripts use itswatchmode to restart the API or worker after a source change. You do not invoke it directly in this guide.
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
Most of the map is boring, which is the good news. Three rows are not:
| Concern | Python | TypeScript | Why it is not a swap |
|---|---|---|---|
| Validation | pydantic | zod | The schema is the source. The type is generated from it, not the reverse |
| Type check | mypy | TypeScript (pnpm typecheck) | Both check source without validating data that arrives at runtime |
| Job queue | celery + Redis | bullmq (Redis-backed queue) or SQL | Postgres 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.
| TypeScript | Python / note |
|---|---|
(x) => expression | anonymous 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 } = request | extract the model and seqLen properties from request |
const [first] = xs | first = 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 : b | a if cond else b |
const / let | Both bind a name. const forbids rebinding, while let allows it |
export | makes a name importable |
switch / case | match, except cases fall through unless they end in break or return |
for await | iterating 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
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
Tool-calling agent frameworks wrap the same basic loop:
- Call the model with tool definitions.
- Validate and run the requested tools.
- Append the results to the messages.
- 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:
- the fragment accumulator
JSON.parseand its error path- the call that runs Zod over the parsed arguments
- provider-specific message assembly
- the step counter
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:
- The hand-written loop converts it with
z.toJSONSchema. - The AI SDK receives it unchanged.
- The MCP server publishes its shape.
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.
| Week | Read | Build |
|---|---|---|
| 1 | javascript.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-2 | Read 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. |
| 2 | Read the Hono, Drizzle, Vitest, and Biome documentation. | Build a streaming proxy to an OpenAI-compatible endpoint with a Drizzle-backed log. |
| 3 | Read 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
- Install Node 24 and pnpm. Then use the repository’s scripts:
pnpm demo,pnpm dev:api,pnpm dev:worker, andpnpm check. The scripts hide the lower-level runtime and type-checker commands. - 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. - 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.
- Write one agent loop by hand if you need to learn or customize the hidden paths: fragment accumulation, validation, and tool-error feedback.
- Make the loop an async generator. The HTTP route consumes its
AgentEventvalues, emits SSE frames, accumulates the text, and persists it after the stream. Tests consume the generator separately with no network or API key. - 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.
- 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
- slavadubrov/typescript-agent-service - the monorepo used throughout this article: Hono API with SSE, two agent loop implementations, Drizzle storage, worker, MCP server, and 40 tests
Runtime and language
- Running TypeScript natively in Node.js - Node’s limited TypeScript support and its
node_modulesrestriction - TypeScript compiler options -
strictand the other checks configured by the companion - MDN JavaScript Guide - the language reference worth keeping open
- javascript.info - modern JavaScript tutorial. Read the modules and promises chapters.
Tooling
- pnpm and pnpm installation - package manager, workspaces, and setup
- Biome - lint, format, and import sorting in one binary
- Vitest - test runner that needs no transform configuration
- Total TypeScript - free tutorials plus a paid track for advanced types
Libraries
- Zod - schema validation and type inference. Version 4 includes
z.toJSONSchema. - Hono - Web-standard HTTP framework
- Drizzle ORM - SQL-first TypeScript ORM with
drizzle-kitmigrations - PostgreSQL SELECT documentation - the
FOR UPDATE ... SKIP LOCKEDlocking clause - BullMQ - Redis-backed queue for when the database table is not enough
AI and agents
- Vercel AI SDK -
streamText,tool,stopWhen, and provider adapters - openai/openai-node - the official TypeScript client
- MCP TypeScript SDK and the MCP specification - building servers and clients
Conventions
- goldbergyoni/nodebestpractices - community-maintained checklist for production conventions