Ten TypeScript files go in, five artefacts come out, and the client is typed in Node, Deno, Bun and the browser. One build script does it, and the SDK has no dependencies.
The constraint: an orchestrator should be able to import the client straight from a URL and get types, and nothing I host may be a second copy of the code that drifts from the first.
I'm Panth, and I lead the software team at Oizom. This is from ticket-tracker, my open-source board, where AI agents work tickets alongside people. Writing a client by hand for every orchestrator is the thing the app exists to avoid, so it ships one: @tm/sdk, hosted next to the API it talks to.
/lib/v1/sdk.js ESM bundle, no dependencies (browsers, Deno, Bun)
/lib/v1/sdk.min.js the same, minified
/lib/v1/sdk.ts the whole SDK as ONE TypeScript file
/lib/v1/sdk.d.ts one flat declaration file, no imports
/lib/v1/tm-sdk-1.6.0.tgz and tm-sdk.tgz, for npm install
/lib/v1/openapi.json OpenAPI 3.1 for /v1
/lib/v1/sdk.json version, byte sizes, SHA-256 of every file
/lib/latest/… the same files, never cached
The v1 is the API's wire version, not the package's. A breaking change to the wire format would get /lib/v2/, and /lib/v1/ keeps working. A new SDK build for the same wire version replaces the files in place under a new tarball name.
sdk/build.mjs is 237 lines. The order of the steps is the whole idea:
sdk/src/*.ts ──concat, imports stripped──▶ dist/sdk.ts
│
tsc --emitDeclarationOnly │ esbuild
▼ ▼
dist/sdk.d.ts dist/sdk.js + sdk.min.js
│ npm pack
▼
dist/tm-sdk-<version>.tgz
Step one glues the 10 modules in sdk/src/ into one file, in a fixed order, and drops every import … from './x.js' line. Anything that imports a package rather than a sibling is a bug, so the script throws instead of dropping it:
const from = stmt.match(/from\s+'([^']+)'/);
if (from && !from[1].startsWith('.'))
throw new Error(`${file}: the SDK must have no dependencies, but it imports '${from[1]}'`);
Everything else is derived from that one file. That's what keeps the promise cheap.
sdk.d.ts`` tsc from a file that has no imports. Inside the repo the SDK's types come from the same shared zod schemas the server validates with, so a wrong field is a compile error rather than a 400. But a URL import can't resolve zod or the shared package. Emitting from the concatenated file means the declaration file can't come out mentioning them. It is also a full strict type-check on the way.sdk.ts`` sdk.js and sdk.min.js``/// <reference types="./sdk.d.ts" />, which Deno and Bun read. package.json with types first in its exports map, so tsc and editors resolve the declaration file. Node can't import over https, so this is the supported path there.
At the end, the build imports each bundle, checks the exports and the version stamp, creates a client with a fake fetch, and asks the MCP tool factory for a tool. It also fails if sdk.d.ts has a single import line. A broken build never reaches the deploy.
Hosting is Firebase, and the headers come from firebase.json. Three rules matter.
| Path | Header | Why |
|---|---|---|
/lib/v1/** |
Cache-Control: public, max-age=31536000, immutable |
A URL import in a hot loop must not cost a round trip |
/lib/latest/** |
Cache-Control: no-store |
A moving name is only useful if it moves |
/lib/**/*.ts |
Content-Type: application/typescript |
Otherwise .ts goes out asvideo/mp2t |
The last one is real. Left alone, Hosting serves a .ts file as an MPEG transport stream, and Deno refuses it. .d.ts also ends in .ts, so one rule covers both. Next to it, /lib/v1/sdk*.js gets X-TypeScript-Types: /lib/v1/sdk.d.ts, which is what makes import … from 'https://…/sdk.js' typed in Deno.
Header rules match the request path, not the file. /lib/index.html doesn't match a request for /lib/, so the install page's rule is spelled three ways. This looks right in review and is wrong in production, which is why the headers are asserted rather than argued: a check starts the real Hosting emulator on the real config, fetches all eighteen files and asserts the content type, cache rule, CORS header and X-TypeScript-Types on each.
"It compiles" proves little if the types never loaded, because untyped code compiles too. sdk/fixtures/check.mjs runs one check per runtime against a local stand-in that sends the same headers as firebase.json:
npm i the real tarball into a project of its own, then tsc --strict. deno check a script that imports X-TypeScript-Types header.
Every fixture ends in @ts-expect-error lines. They fail if the mistake compiles, and they also fail if the types never resolved, since an expected error that never happens is itself an error. So "no types" can't pass as "types fine". And there is a negative control: the same bundle with both type hints removed must fail deno check. It does. If it ever passed, the checks above it would prove nothing, and the script says so.
The SDK is built by pnpm sdk:build, not by the deploy. So the deploy checks what it's about to upload: every artefact exists, carries the version sdk/package.json claims, starts with the triple-slash reference, has a flat declaration file, and is newer than every file under sdk/src. If not, it stops and prints the command to run.
The failure it guards against is quiet: yesterday's bundle going out next to today's API. Nothing would error. A field would just be missing from the types until someone hit it.
The rule is "derive, don't copy", and the price is mostly the checks. The build script is 237 lines, and the fixture and header checks add their own. In return there is one place to change the client, and the four runtimes see the same code with the same types.
How do you ship a typed client to users who import it over a URL rather than from npm?