ts-sdk-author

v2026.09.25

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.

GitHub
Install command
npx skhub add mindfold-ai/ts-sdk-author
Markdown
SKILL.md

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:

  1. Adopt apps/ + packages/ + optional tools/ at the workspace root
  2. Place the SDK in packages/<sdk-name>/ (or packages/core/)
  3. Give it a scoped name (@<org>/<sdk-name>)
  4. Wire workspace-internal deps with the workspace:* protocol
  5. 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
# 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.

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:

// 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 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:

  1. types must be first inside each import / require branch
  2. default must be last
  3. Dual ESM+CJS needs separate .d.mts and .d.cts (TS 5.0+)
  4. Always include "./package.json": "./package.json" (lets publint/attw introspect)
  5. 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                   # 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 commit

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:

# 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 reaches

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:

# 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 needed

Convention 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.0

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

FileOwnsQuick check
pnpm-workspace.yamlWhich dirs are packagesapps/* + packages/*
Root package.jsonWorkspace devDeps + turbo run delegatesNo package-level build script in root
Package package.jsonname, version, type, exports, files, sideEffects, bin, scripts.prepublishOnlyRun publint --strict
Package tsconfig.jsonEditor + tsc --noEmitstrict: true + declaration: true
tsconfig.build.jsonLibrary build configisolatedDeclarations: true if you want fast .d.ts
tsdown.config.tsBundlingformat: ['esm', 'cjs'] + dual outExtensions
turbo.jsonTask pipelinedependsOn: ['^build'] for compile order
.changeset/config.jsonRelease policycommit: false, access: public

Bundler Selection at a Glance

SituationChoice
Modern TS SDK, dual ESM+CJS, plugin subpathstsdown
Zero runtime deps, want raw source-faithful publishtsc only / zshy
Existing project on tsup that worksStay on tsup; plan tsdown migration
UnJS / Nuxt ecosystemunbuild
Need bundle-splitting + advanced rollup configDirect rolldown

Module-Format Decision

SituationRecommendation
Default for new SDK in 2026dual ESM + CJS
Library has stable consumer base ≥ Node 22ESM-only is defensible
Library is internal-only inside a Node appESM-only
Library is consumed by Jest, older Next.js, Lambda CJSdual is mandatory

Release Tag at a Glance

TagMeaningnpm install pkg@? resolves
latestThe current stablenpm install pkg
nextUpcoming major prereleasenpm install pkg@next
betaFeature-complete stabilizationnpm install pkg@beta
rcFrozen, blocker-onlynpm install pkg@rc
canaryPer-commit/per-PR snapshotnpm install pkg@canary
experimentalUnstable spikenpm install pkg@experimental

One-Liner Snippets You'll Type Often

# 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-checks

Pre-Publish Checklist

Run through this once per release. Skipping any item is how broken SDKs ship.

Build artifact

  • 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
  • Source maps emitted (.mjs.map, .d.mts.map)
  • dist/ size is reasonable (du -sh dist/ — sanity check, no surprise bloat)

package.json

  • name is scoped (@org/name) — required if you'll ever go private later
  • version matches what you're about to publish
  • type matches your default format ("module" for ESM-default, omit for CJS-default)
  • exports has "./package.json": "./package.json"
  • Every exports branch has types first, default last
  • files 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 publish
  • publishConfig.provenance: true

Verification

  • publint --strict passes
  • attw --pack . passes (or only has expected node10 warnings)
  • Smoke test: pack + install in /tmp + CJS + ESM + TS consumer all resolve

Release

  • Correct dist-tag chosen (latest only for stable)
  • If pre-release: pnpm changeset pre enter <tag> was run BEFORE version
  • If stable: pnpm changeset pre exit was run if previously in pre-mode
  • CHANGELOG.md reflects the change
  • Git tag matches version (e.g. v1.0.0-beta.3)
  • Provenance attestation visible on npm package page

Common Mistakes

MistakeWhat goes wrongFix
types not first inside exports branchTS picks up .js as type source → cascade of errors at consumerMove types to top of each import / require branch (publint will flag)
Single .d.ts for dual ESM+CJSTS resolves the .d.ts against the wrong module modeEmit .d.mts + .d.cts (TS 5.0+); tsdown does this automatically
Missing "./package.json": "./package.json" in exportspublint/attw cannot introspect your packageAlways include it
Publishing pre-release to latestEvery npm install pkg user gets your betaUse npm publish --tag beta; recover via npm dist-tag add pkg@stable latest
Forgetting pnpm changeset pre exit before stable releaseStable version comes out as 1.0.0-beta.N instead of 1.0.0Always pre exit before final
Root package.json containing the actual build scriptDefeats Turborepo parallelism + cachingPer-package scripts; root only delegates via turbo run
Deep imports into dist/internal/... from consumersConsumers couple to internals; your refactors break themDon't list internals in exports; use ESLint no-restricted-imports
Leaking internal types into public API surfaceUsers see types they shouldn't depend onRe-export only from src/api/*.ts; don't export * from internals
Missing sideEffects: false with no side effectsBundlers can't tree-shake your libraryAdd "sideEffects": false or list the actual side-effecting files
Forgetting id-token: write permission for provenanceProvenance attestation fails silently in CIAdd permissions: { id-token: write, contents: read } to release job
Mixing watch + build in same Turbo taskCache invalidates constantly; watch never settlesSeparate build (cacheable) and dev (persistent: true, cache: false)
exports with both module and unrelated runtime conditions in wrong orderEdge runtime picks the wrong fileRuntime conditions (browser, workerd) → module → import → require → default
Using enum in public API typesForces consumers into TS-only land, breaks erasable syntaxUse union of string literals or as const objects
Using default export from the SDK rootBreaks tree-shaking + interop storyAlways named exports

Reference Files

FileUse when
references/workspace-and-layout.mdSetting 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.mdSplitting 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.mdBranded 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.mdAuthoring 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.mdChoosing 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.mdWriting 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.mdpublint + 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.

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

AGPL-3.0

Source path

.agents/skills/ts-sdk-author

Default branch

main

Latest commit

e77ae89

Tree SHA

ca1ef1a