| name | ts-sdk-author |
| description | 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.
|
| license | MIT |
| metadata | {"author":"oh-my-openclaw","version":"1.0","composed_from":["agent-cli-architecture","typescript-pro","turborepo","monorepo-navigator"],"sources":["code-architecture-refactoring","architecture-patterns","turborepo","fastify","Jeffallan/claude-skills (typescript-pro)","turborepo (official docs)","monorepo-navigator","publint.dev (rule catalog)","arethetypeswrong.github.io (problem catalog)","tsdown.dev (official docs)","changesets/changesets (prerelease + dist-tags docs)","GitHub package.json originals — tRPC, vercel/ai, Inngest, Sanity client, Hono, Zustand, TanStack query-core"]} |
TypeScript SDK Author
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.
When to Use This Skill
- Extracting a core library (e.g.
packages/core, packages/sdk) out of an
existing CLI or app inside a pnpm workspace
- Designing the public API surface of a TypeScript library that strangers will
consume — branded types, generic clients, plugin extension points
- Choosing a build tool — tsdown vs tsup vs tsc-only vs unbuild
- Authoring the
package.json exports field with dual ESM+CJS, isomorphic
runtime conditions, and subpath plugin entries
- Configuring Turborepo so the SDK rebuilds only when its inputs change and
downstream apps consume the SDK's build output (or raw
src via a custom
condition)
- Wiring
publint --strict and attw --pack into prepublishOnly or CI
- Managing pre-release channels —
canary 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)
- Setting up changesets with GitHub Actions
changesets/action@v1 plus
npm provenance
Execution Workflow
A single TS SDK build flows through these seven phases. Skip any phase and
something will break later — the dependencies between phases are real.
Phase 1 — Workspace & Package Skeleton
Lay down the monorepo and create the empty SDK package.
Core moves:
- Adopt
apps/ + packages/ + optional tools/ at the workspace root
- Place the SDK in
packages/<sdk-name>/ (or packages/core/)
- Give it a scoped name (
@<org>/<sdk-name>)
- Wire workspace-internal deps with the
workspace:* protocol
- Decide BEFORE anything else: this package will eventually be published,
so design the boundary and naming with that in mind
my-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
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.
Phase 2 — Public API Surface
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 exported
B. Type design. SDK types are consumed by strangers, must not leak
internals, must be evolvable. Use:
- Branded types for opaque IDs:
type UserId = Brand<string, "UserId">
- Generic clients with sensible defaults so adding type params later is
non-breaking:
createClient<Schema = DefaultSchema>(...)
- Discriminated unions for result types:
Result<T, E> with
{ ok: true; value: T } | { ok: false; error: E }
- Builder pattern for type-safe configuration when option combinations
matter
- Interfaces (not type aliases) when users may need to extend the type
via declaration merging
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-patterns
references/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 evolution
Phase 3 — Build Configuration
You need (a) a tsconfig.json tuned for library output, and (b) a bundler
that produces the actual dist/.
tsconfig for libraries — the critical flags:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"verbatimModuleSyntax": true,
"isolatedDeclarations": true,
"composite": true,
"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 publish
unbuild — only when already in UnJS ecosystem
tsup — community familiarity but losing mind-share; viable for inertia
Read next:
references/tsdown-bundling.md — §3 verdict, §4 working config, §6–§8
alternatives, §11 selection decision tree
references/type-design-for-public-api.md §7 — full library tsconfig
walkthrough
Phase 4 — Distribution Shape (package.json exports)
This is where most TS SDK bugs live. Five invariants:
types must be first inside each import / require branch
default must be last
- Dual ESM+CJS needs separate
.d.mts and .d.cts (TS 5.0+)
- Always include
"./package.json": "./package.json" (lets publint/attw introspect)
module before require if you use both
The 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.
Phase 5 — Monorepo Pipeline (Turborepo)
Once the SDK builds in isolation, wire it into the workspace so:
- Apps rebuild only when SDK output changes (caching)
- Local dev rebuilds SDK in watch mode while the app reloads
- CI builds only affected packages on PRs
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
pnpm turbo run dev --filter=@acme/sdk... --filter=@acme/example-app
pnpm turbo run test --filter=...@acme/sdk
pnpm turbo run lint --filter=[HEAD^1]
Critical 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.
Phase 6 — Verification
Before publish, two static checks + one runtime check are non-negotiable:
pnpm exec publint --strict
pnpm exec attw --pack .
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'))"
node --input-type=module -e "import('@acme/sdk').then(console.log)"
Wire 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).
Phase 7 — Release Lifecycle
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 patches
npm dist-tags — never publish a pre-release to latest:
npm publish --tag beta
npm dist-tag add @acme/sdk@1.0.0 latest
npm dist-tag rm @acme/sdk beta
Convention tags: latest (stable), next (upcoming major prerelease),
beta, rc, canary (per-commit), alpha, experimental, nightly.
changesets pre-release mode — the canonical transitions:
pnpm changeset pre enter beta
pnpm changeset
pnpm changeset version
pnpm changeset publish
pnpm changeset pre exit
pnpm changeset pre enter rc
pnpm changeset version
pnpm changeset publish
pnpm changeset pre exit
pnpm changeset version
pnpm changeset publish
pnpm changeset pre enter beta
pnpm changeset version
npm 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.
Quick Reference
File / Field Cheat Sheet
| 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 |
Bundler Selection at a Glance
| 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 |
Module-Format Decision
| 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 |
Release Tag at a Glance
| 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 |
One-Liner Snippets You'll Type Often
pnpm add @acme/sdk@workspace:* --filter @acme/example-app
pnpm turbo run build --filter=...@acme/sdk
pnpm build && pnpm exec publint --strict && pnpm exec attw --pack .
pnpm changeset version --snapshot pr-123
pnpm publish --tag pr-123 --no-git-checks
Pre-Publish Checklist
Run through this once per release. Skipping any item is how broken SDKs
ship.
Build artifact
package.json
Verification
Release
Common Mistakes
| 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 |
Reference Files
| 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 |
Source Skills
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.