CtrlK
BlogDocsLog inGet started
Tessl Logo

ts-sdk-author

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

Quality

92%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

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.Nrc.01.0.01.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) → moduleimportrequiredefault
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.

Repository
mindfold-ai/Trellis
Last updated
First committed

Is this your skill?

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.