Design, build, verify, and publish production-grade TypeScript SDKs as npm packages inside a pnpm monorepo. Covers workspace layout, public API and module boundaries, plugin extension points, branded types and library-tuned tsconfig, tsdown bundling (vs tsup/tsc-only/unbuild), package.json exports with dual ESM+CJS and isomorphic conditions (browser/workers/RN/deno), Turborepo pipelines, publint and @arethetypeswrong/cli verification, changesets pre-release mode, npm dist-tags (latest/next/beta/rc/canary), and the alpha→beta→rc→stable release lifecycle. Triggers on: build a TS SDK, extract core library, package.json exports, dual ESM CJS, tsdown config, tsup vs tsdown, publint, attw, changesets prerelease, npm dist-tag, beta to rc, canary release, pnpm workspace SDK, isomorphic SDK, tsconfig library, npm provenance, shipping a TypeScript library.
73
92%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
End-to-end workflow for shipping a TypeScript SDK as a standalone npm package from inside a pnpm monorepo: workspace layout, public API design, build configuration, distribution shape, monorepo pipeline, verification, and the full release lifecycle including beta / rc / canary channels.
The seven references hold the depth. This file is the unified workflow plus a quick-reference for the patterns you reach for daily.
packages/core, packages/sdk) out of an
existing CLI or app inside a pnpm workspacepackage.json exports field with dual ESM+CJS, isomorphic
runtime conditions, and subpath plugin entriessrc via a custom
condition)publint --strict and attw --pack into prepublishOnly or CIcanary per commit, next for the upcoming
major, beta / rc for stabilization, latest for stable — and the
transitions between them (beta.N → rc.0 → 1.0.0 → 1.1.0-beta.0)changesets/action@v1 plus
npm provenanceA single TS SDK build flows through these seven phases. Skip any phase and something will break later — the dependencies between phases are real.
Lay down the monorepo and create the empty SDK package.
Core moves:
apps/ + packages/ + optional tools/ at the workspace rootpackages/<sdk-name>/ (or packages/core/)@<org>/<sdk-name>)workspace:* protocolmy-repo/
├── pnpm-workspace.yaml
├── package.json # root: only devDeps + workspace scripts
├── apps/
│ └── example-app/ # consumer of the SDK
└── packages/
├── sdk/ # ← the SDK
└── shared-tsconfig/ # internal-only, never published# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"Read next: references/workspace-and-layout.md — §2 layout, §3 SDK naming
patterns, §4 internal package creation, §5 package.json skeleton, §7 multi
-repo → monorepo migration.
Before writing any code, decide what the SDK's public face looks like.
Two parallel concerns:
A. Module boundaries. The src/ tree splits cleanly into api/ (what
gets re-exported and is part of the contract) and internal/ (do not import
from outside the package). The package.json exports field is your
cheapest enforcement mechanism — anything not listed there cannot be
imported by consumers.
packages/sdk/src/
├── index.ts # barrel — re-exports from api/
├── api/
│ ├── client.ts
│ └── types.ts
└── internal/
├── transport.ts # NOT exported
└── state.ts # NOT exportedB. Type design. SDK types are consumed by strangers, must not leak internals, must be evolvable. Use:
type UserId = Brand<string, "UserId">createClient<Schema = DefaultSchema>(...)Result<T, E> with
{ ok: true; value: T } | { ok: false; error: E }Read next:
references/module-boundaries-and-plugins.md — §2 src/ boundary,
§3 runtime layering, §4 provider/adapter, §5 plugin extension, §6 boundary
enforcement, §7 patterns vs anti-patternsreferences/type-design-for-public-api.md — §1 branded types, §2 generic
surfaces, §3 conditional/mapped types, §4 type guards, §5 builder, §6
utility types ship/internal, §7 tsconfig for libraries, §8 API evolutionYou need (a) a tsconfig.json tuned for library output, and (b) a bundler
that produces the actual dist/.
tsconfig for libraries — the critical flags:
// tsconfig.build.json — library build config
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"declaration": true, // emit .d.ts
"declarationMap": true, // sourcemap from .d.ts → .ts
"sourceMap": true,
"verbatimModuleSyntax": true, // TS 5.0+ — strict import elision
"isolatedDeclarations": true, // TS 5.5+ — explicit return types on public API
"composite": true, // enable project references
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}Bundler choice in 2026: tsdown. tRPC and Inngest migrated to it from
tsup; tsup's own README now says "This project is not actively maintained
anymore. Please consider using tsdown instead."
Minimum viable tsdown.config.ts:
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts", "src/plugin/index.ts", "src/testing/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
treeshake: true,
clean: true,
outExtensions: ({ format }) => ({
js: format === "esm" ? ".mjs" : ".cjs",
dts: format === "esm" ? ".d.mts" : ".d.cts",
}),
});Alternatives:
tsc-only / zshy — small SDK with no runtime deps, source-faithful publishunbuild — only when already in UnJS ecosystemtsup — community familiarity but losing mind-share; viable for inertiaRead next:
references/tsdown-bundling.md — §3 verdict, §4 working config, §6–§8
alternatives, §11 selection decision treereferences/type-design-for-public-api.md §7 — full library tsconfig
walkthroughpackage.json exports)This is where most TS SDK bugs live. Five invariants:
types must be first inside each import / require branchdefault must be last.d.mts and .d.cts (TS 5.0+)"./package.json": "./package.json" (lets publint/attw introspect)module before require if you use bothThe canonical dual shape (verbatim from @trpc/server):
{
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}Add subpaths for plugin entry points so they version separately from the root barrel:
{
"exports": {
".": { "import": { ... }, "require": { ... } },
"./plugin": { "import": { ... }, "require": { ... } },
"./testing": { "import": { ... }, "require": { ... } }
}
}For isomorphic SDKs (browser / workers / RN / edge), runtime conditions
come before import / require:
{
".": {
"browser": { "import": "./dist/browser.mjs" },
"workerd": { "import": "./dist/workerd.mjs" },
"react-native": { "import": "./dist/rn.mjs" },
"deno": { "import": "./dist/deno.mjs" },
"import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
}
}Read next: references/package-json-exports.md — §3 the five rules,
§4 tRPC dual pattern annotated, §5 ESM-only pattern, §6 subpath plugins,
§7 isomorphic conditions (Sanity client pattern), §9 common mistakes
bad → fixed → why.
Once the SDK builds in isolation, wire it into the workspace so:
Minimum viable turbo.json:
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig*.json", "tsdown.config.ts", "package.json"],
"outputs": ["dist/**"]
},
"test": { "dependsOn": ["^build"], "inputs": ["src/**", "test/**"] },
"lint": { "inputs": ["src/**"] },
"typecheck": { "dependsOn": ["^build"], "inputs": ["src/**", "tsconfig*.json"] },
"dev": { "persistent": true, "cache": false }
}
}Daily --filter patterns:
pnpm turbo run build --filter=@acme/sdk # SDK alone
pnpm turbo run dev --filter=@acme/sdk... --filter=@acme/example-app
pnpm turbo run test --filter=...@acme/sdk # affected-by-SDK
pnpm turbo run lint --filter=[HEAD^1] # affected since last commitCritical rules: put scripts in each package's package.json, not in
root. Root only delegates turbo run X.
Read next: references/turborepo-for-sdk.md — §2 minimum viable
turbo.json, §3 per-package vs root, §4 dependsOn, §5 caching
inputs/outputs, §6 --filter patterns, §7 boundaries field, §8 CI patterns,
§9 dev mode with watch.
Before publish, two static checks + one runtime check are non-negotiable:
# After pnpm build:
pnpm exec publint --strict # static lint of package.json
pnpm exec attw --pack . # simulate Node/Bun/Deno/bundler resolution
# Then pack + install in a sandbox dir
pnpm pack
cd /tmp/sandbox && npm init -y && npm install /path/to/your-pkg-1.0.0.tgz
node -e "console.log(require('@acme/sdk'))" # CJS reaches
node --input-type=module -e "import('@acme/sdk').then(console.log)" # ESM reachesWire all three into prepublishOnly:
{
"scripts": {
"prepublishOnly": "pnpm build && pnpm exec publint --strict && pnpm exec attw --pack ."
}
}Why both publint and attw? publint statically checks package.json
shape; attw actually simulates how each consumer runtime resolves your
tarball. The most common attw failure is Masquerading ESM — a .js
file that contains ESM but is exposed under require — which publint
cannot catch.
Read next: references/verification-and-publishing.md — §2 publint
rules + 3 common failures, §3 attw resolution-mode table + 7 failure modes,
§4 smoke tests (tarball → fresh dir).
This is where most SDK projects accumulate debt. Get it right from day 1.
Semver + pre-release identifiers:
0.x.y # pre-1.0 — breaking changes allowed in minors
1.0.0-alpha.0 # internal feature spike
1.0.0-beta.0 # feature-complete, API may still shift
1.0.0-rc.0 # frozen, blocker-only fixes
1.0.0 # stable
1.0.1 # patch on stable
1.1.0-beta.0 # next minor's beta cycle while 1.0.x ships patchesnpm dist-tags — never publish a pre-release to latest:
# Publish a beta under the `beta` tag (NOT `latest`)
npm publish --tag beta
# Recover from a mistaken latest:
npm dist-tag add @acme/sdk@1.0.0 latest # repoint latest to stable
npm dist-tag rm @acme/sdk beta # if no longer neededConvention tags: latest (stable), next (upcoming major prerelease),
beta, rc, canary (per-commit), alpha, experimental, nightly.
changesets pre-release mode — the canonical transitions:
# Cut beta line
pnpm changeset pre enter beta
pnpm changeset # write a changeset
pnpm changeset version # bumps to 1.0.0-beta.0
pnpm changeset publish
# Feature-complete; move beta → rc
pnpm changeset pre exit
pnpm changeset pre enter rc
pnpm changeset version # bumps to 1.0.0-rc.0
pnpm changeset publish
# RC stable; ship 1.0.0
pnpm changeset pre exit
pnpm changeset version # bumps to 1.0.0
pnpm changeset publish
# Open next minor's beta line
pnpm changeset pre enter beta
pnpm changeset version # bumps to 1.1.0-beta.0npm provenance — turn it on:
{
"publishConfig": {
"access": "public",
"provenance": true
}
}Pair with id-token: write permission in the GitHub Actions release job;
npm will display a verified attestation on the package page.
Read next: references/verification-and-publishing.md — §5 semver
refresher, §6 dist-tag rules, §7 full lifecycle state diagram, §8 case
studies (Next.js / vercel-ai / tRPC / Storybook / Stripe with real version
sequences), §9 changesets pre-release flow, §10 GitHub Actions release
workflow, §11 provenance, §12 yank vs deprecate, §13 strategy decision tree.
| File | Owns | Quick check |
|---|---|---|
pnpm-workspace.yaml | Which dirs are packages | apps/* + packages/* |
Root package.json | Workspace devDeps + turbo run delegates | No package-level build script in root |
Package package.json | name, version, type, exports, files, sideEffects, bin, scripts.prepublishOnly | Run publint --strict |
Package tsconfig.json | Editor + tsc --noEmit | strict: true + declaration: true |
tsconfig.build.json | Library build config | isolatedDeclarations: true if you want fast .d.ts |
tsdown.config.ts | Bundling | format: ['esm', 'cjs'] + dual outExtensions |
turbo.json | Task pipeline | dependsOn: ['^build'] for compile order |
.changeset/config.json | Release policy | commit: false, access: public |
| Situation | Choice |
|---|---|
| Modern TS SDK, dual ESM+CJS, plugin subpaths | tsdown |
| Zero runtime deps, want raw source-faithful publish | tsc only / zshy |
| Existing project on tsup that works | Stay on tsup; plan tsdown migration |
| UnJS / Nuxt ecosystem | unbuild |
| Need bundle-splitting + advanced rollup config | Direct rolldown |
| Situation | Recommendation |
|---|---|
| Default for new SDK in 2026 | dual ESM + CJS |
| Library has stable consumer base ≥ Node 22 | ESM-only is defensible |
| Library is internal-only inside a Node app | ESM-only |
| Library is consumed by Jest, older Next.js, Lambda CJS | dual is mandatory |
| Tag | Meaning | npm install pkg@? resolves |
|---|---|---|
latest | The current stable | npm install pkg |
next | Upcoming major prerelease | npm install pkg@next |
beta | Feature-complete stabilization | npm install pkg@beta |
rc | Frozen, blocker-only | npm install pkg@rc |
canary | Per-commit/per-PR snapshot | npm install pkg@canary |
experimental | Unstable spike | npm install pkg@experimental |
# Add the SDK as a workspace-internal dep
pnpm add @acme/sdk@workspace:* --filter @acme/example-app
# Build SDK + everything that depends on it
pnpm turbo run build --filter=...@acme/sdk
# Pre-publish gate
pnpm build && pnpm exec publint --strict && pnpm exec attw --pack .
# Cut a snapshot release for a PR (vercel/ai pattern)
pnpm changeset version --snapshot pr-123
pnpm publish --tag pr-123 --no-git-checksRun through this once per release. Skipping any item is how broken SDKs ship.
pnpm build produces dist/ with both .mjs and .cjs (if dual) or just .mjs (if ESM-only).d.mts and .d.cts exist for dual, OR .d.ts only for ESM-only.mjs.map, .d.mts.map)dist/ size is reasonable (du -sh dist/ — sanity check, no surprise bloat)name is scoped (@org/name) — required if you'll ever go private laterversion matches what you're about to publishtype matches your default format ("module" for ESM-default, omit for CJS-default)exports has "./package.json": "./package.json"exports branch has types first, default lastfiles lists dist (and src if shipping sources for IDE jump-to-def)sideEffects: false (unless you genuinely have top-level side effects)publishConfig.access: "public" for first scoped publishpublishConfig.provenance: truepublint --strict passesattw --pack . passes (or only has expected node10 warnings)/tmp + CJS + ESM + TS consumer all resolvelatest only for stable)pnpm changeset pre enter <tag> was run BEFORE versionpnpm changeset pre exit was run if previously in pre-modev1.0.0-beta.3)| Mistake | What goes wrong | Fix |
|---|---|---|
types not first inside exports branch | TS picks up .js as type source → cascade of errors at consumer | Move types to top of each import / require branch (publint will flag) |
Single .d.ts for dual ESM+CJS | TS resolves the .d.ts against the wrong module mode | Emit .d.mts + .d.cts (TS 5.0+); tsdown does this automatically |
Missing "./package.json": "./package.json" in exports | publint/attw cannot introspect your package | Always include it |
Publishing pre-release to latest | Every npm install pkg user gets your beta | Use npm publish --tag beta; recover via npm dist-tag add pkg@stable latest |
Forgetting pnpm changeset pre exit before stable release | Stable version comes out as 1.0.0-beta.N instead of 1.0.0 | Always pre exit before final |
Root package.json containing the actual build script | Defeats Turborepo parallelism + caching | Per-package scripts; root only delegates via turbo run |
Deep imports into dist/internal/... from consumers | Consumers couple to internals; your refactors break them | Don't list internals in exports; use ESLint no-restricted-imports |
| Leaking internal types into public API surface | Users see types they shouldn't depend on | Re-export only from src/api/*.ts; don't export * from internals |
Missing sideEffects: false with no side effects | Bundlers can't tree-shake your library | Add "sideEffects": false or list the actual side-effecting files |
Forgetting id-token: write permission for provenance | Provenance attestation fails silently in CI | Add permissions: { id-token: write, contents: read } to release job |
| Mixing watch + build in same Turbo task | Cache invalidates constantly; watch never settles | Separate build (cacheable) and dev (persistent: true, cache: false) |
exports with both module and unrelated runtime conditions in wrong order | Edge runtime picks the wrong file | Runtime conditions (browser, workerd) → module → import → require → default |
Using enum in public API types | Forces consumers into TS-only land, breaks erasable syntax | Use union of string literals or as const objects |
Using default export from the SDK root | Breaks tree-shaking + interop story | Always named exports |
| File | Use when |
|---|---|
references/workspace-and-layout.md | Setting up apps/ + packages/ + tools/; naming the SDK package; creating internal packages; choosing dep field (dependencies / peerDependencies / devDependencies); migrating from multi-repo |
references/module-boundaries-and-plugins.md | Splitting src/ into api/ vs internal/; designing the orchestration layer vs adapters vs tools; building a plugin extension model with lifecycle hooks; enforcing boundaries via eslint-plugin-boundaries / dependency-cruiser / Turbo boundaries |
references/type-design-for-public-api.md | Branded types; generic clients; conditional + mapped types; type-safe builders; which utility types to ship vs keep internal; tsconfig flags for libraries (verbatimModuleSyntax, isolatedDeclarations, composite); API evolution patterns |
references/package-json-exports.md | Authoring the exports field; dual ESM+CJS with separate .d.mts/.d.cts; subpath plugin entries; isomorphic runtime conditions (browser, workerd, react-native, deno, edge-light); fixing common exports bugs |
references/tsdown-bundling.md | Choosing tsdown vs tsup vs tsc-only vs unbuild; minimum-viable tsdown.config.ts; subpath output mapping; side-effects + tree-shaking; watch & dev mode; bundler selection decision tree |
references/turborepo-for-sdk.md | Writing turbo.json for an SDK monorepo; dependsOn: ['^build']; caching inputs/outputs; --filter patterns for SDK dev; the boundaries field; CI with remote cache + affected-only builds; dev mode with persistent: true |
references/verification-and-publishing.md | publint + attw setup; tarball smoke tests; semver + pre-release identifiers; npm dist-tags; the full beta → rc → stable → next-cycle state machine; changesets pre-release mode; the canonical beta → rc transition command sequence; GitHub Actions release workflow with snapshot PRs; npm provenance; yank vs deprecate; release-strategy decision tree |
This skill was composed from four source skills inside oh-my-openclaw:
agent-cli-architecture (architect-claw) — workspace structure, module
boundaries, runtime layering, plugin extension patterns. Generalized from
"agent CLI" framing to general "SDK + supporting CLI".typescript-pro (frontend-claw) — branded types, generics, conditional
types, type guards, utility types, tsconfig deep dive. Reframed toward
library/SDK-author concerns.turborepo (frontend-claw) — task pipelines, caching, --filter,
boundaries. Heavily trimmed to SDK-monorepo-relevant subset.monorepo-navigator (architect-claw) — pnpm workspaces, changesets,
publishing, migration.Plus original research on package.json exports, dual ESM/CJS in 2026,
the tsdown landscape, publint + attw, and the alpha→beta→rc→stable
lifecycle, with verbatim examples from the GitHub package.json of
tRPC, vercel/ai, Inngest, Sanity client, Hono, Zustand, and TanStack
query-core.
12e279a
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.