How-to › Ship an SDK

How to package a customization several SDK projects can install#

Turn a feature you copy between sdkgen projects into a versioned package, install it into two projects, and roll both forward while each keeps its own settings.

Audience
Platform team
Level
advanced
Topic
Customize a generator without forking
Languages
TypeScript
Verified

Three SDK repositories each carry a copy of the feature that stamps your product header on every request. A fix for a duplicated header lands in one copy, while the other two keep sending the header twice whenever a caller sets it. Because the copies carry no version, nothing tells you which one is current.

What you get

You will end up with a versioned sdkgen package installed into two projects that differ, and a test that rolls both forward and checks each kept its own settings. This is for you if your team copies the same customization between SDK repositories.

Short answer

Put the customization in an sdkgen package: a sdkgen-package.json manifest beside a .sdk tree holding the feature’s model and source. Check it with voxgig-sdkgen package check, install it with package add, and roll every project forward with package update. Give project values a default in the package and set them in each project’s model/project.aontu, which regeneration never overwrites.

You will need

Node 22 or later, two or more sdkgen projects, and a customization already working in one of them, such as a custom feature. Verified 2026-10-15 against Node 22.22.0, @voxgig/create-sdkgen 0.30.7 and @voxgig/sdkgen 4.34.7. The sample scaffolds both projects offline from the toolchain that npm ci installs, so every command runs without a network. The package format is set out in sdkgen’s authoring guide, linked from the table that follows.

Voxgig maintains sdkgen. This page compares its packages with a custom generator for OpenAPI Generator and a Copier template repository.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
a Copier template repositoryProject scaffolding in any language, where each project answers the template’s questions oncecopier update merges template changes, and writes conflict markers where a project’s edits collideThe shared piece is runtime behavior inside a generated SDK
an OpenAPI Generator custom generator jar and template setYour SDKs come from OpenAPI Generator and need logic that templates alone cannot expressA Maven build, a jar on every consumer’s Java class path, and a whole generator to maintainYou need to change one behavior, not the generator
sdkgen packagesSeveral sdkgen projects that need the same feature, target or templateA public contract: a manifest, a version in two files, and every consumer’s upgrade to supportFewer than three projects, where copying the folder is still faster

A Copier template keeps each project’s answers in .copier-answers.yml and merges template updates into the files, writing conflict markers by default where a change cannot be applied.1 A custom generator for OpenAPI Generator is a Java project: the meta command scaffolds it, mvn package builds it, and every consumer adds the jar to the Java class path. An sdkgen package copies files into each project and records where each came from, which is what lets it refuse to overwrite a copy somebody changed.

Shape the package like the scaffold

A package is a folder with a manifest and a .sdk tree laid out exactly like a project’s. The manifest names what it provides, by kind.

{
  "sdkgen": { "package": 1 },
  "name": "@acme/sdkgen-headers",
  "version": "1.1.0",
  "engines": { "sdkgen": ">=4.34.0" },
  "provides": {
    "feature": ["acmeheaders"]
  }
}

The feature’s model declares its options. Every option a project might change gets a default, written *value | type, rather than a value:

  config: {
    options: {
      # Defaults, not values: a project may replace either one.
      active: *true | boolean
      header: *'X-Acme-Product' | string
      # Empty until a project names itself. `package check` refuses a value
      # with no default, so the empty string stands in for "not set".
      product: *'' | string
    }
  }

The source sits at .sdk/tm/ts/src/feature/acmeheaders/, where the TypeScript target keeps its features. Run voxgig-sdkgen package check on the package before anyone installs it. It compiles each model the way a consumer’s build will, and exits non-zero on an error.2

Keep project values in the project

Each project states its own decisions in model/project.aontu, which the scaffold creates once and never rewrites. The billing project renames the header and sets its own release version:

main: kit: feature: acmeheaders: config: options: { product: 'billing', header: 'X-Acme-App' }
main: kit: target: ts: publish: version: '0.9.1'

A project switches the feature on when it constructs the client, with feature: { acmeheaders: { active: true } }, because sdkgen’s feature reference says every feature is off until switched on. In 4.34.7 a feature’s model options reach the generated Config but are not runtime defaults, so active: *true in the package does not switch it on. The feature’s init therefore merges the config’s options under the caller’s itself, and a test pins that a client constructed without active: true sends no header.

Install into both projects, then roll forward

The demo scaffolds the ledger and billing projects, installs version 1.0.0 into both, generates, then publishes 1.1.0 and updates both. After each generate it calls each SDK against a local server and prints the headers that arrived.

node share.mjs
install 1.0.0 into both, then generate
  ledger   @acme/sdkgen-headers: added feature `acmeheaders`
  billing  @acme/sdkgen-headers: added feature `acmeheaders`
  ledger   acme 1.0.0  sdk 2.3.0  feature 1.0.0  sends x-acme-product: ledger
           caller sets x-acme-product: set-by-caller, server sees: set-by-caller, ledger
  billing  acme 1.0.0  sdk 0.9.1  feature 1.0.0  sends x-acme-app: billing
           caller sets x-acme-app: set-by-caller, server sees: set-by-caller, billing

release 1.1.0, update both, then generate
  ledger   @acme/sdkgen-headers: 2 outdated file(s) will be refreshed; .sdk/log/copies.jsonl shows nothing in them was changed
  billing  @acme/sdkgen-headers: 2 outdated file(s) will be refreshed; .sdk/log/copies.jsonl shows nothing in them was changed
  ledger   acme 1.1.0  sdk 2.3.0  feature 1.1.0  sends x-acme-product: ledger
           caller sets x-acme-product: set-by-caller, server sees: set-by-caller
  billing  acme 1.1.0  sdk 0.9.1  feature 1.1.0  sends x-acme-app: billing
           caller sets x-acme-app: set-by-caller, server sees: set-by-caller

package list and doctor, in billing
  @acme/sdkgen-headers@1.1.0: feature `acmeheaders`
  @voxgig/docgen@0.30.1: edition `github-pages`, edition `summary`
  @voxgig/sdkgen@4.34.7: feature `test`, target `ts`
  .sdk matches the scaffold (0 additive)

Version 1.0.0 had the bug from the opening: a caller’s own header and the feature’s arrived as one combined value, set-by-caller, ledger. A Headers object appends a second value to a name it already holds, and here the two names differed only in letter case. Version 1.1.0 keeps the caller’s value, and both projects picked it up from one release. Each kept its own header name, product, and version.

package update ran without --force because the copy record, .sdk/log/copies.jsonl, showed that neither project had touched its copies. With npm, the same roll-forward is npm install --save-dev @acme/sdkgen-headers@1.1.0 and then voxgig-sdkgen package update @acme/sdkgen-headers --no-fetch, as the CLI reference sets out.

Check it worked

Seven tests repeat the flow and assert on it: both versions pass package check, each project keeps its settings, both pick up 1.1.0, and doctor is clean afterwards.

node --test share.test.mjs
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0

The run takes about half a minute and prints a line after each project generates.

When it goes wrong

The second project fails to generate. Somebody built the package from the first project’s copy and left its value in the model, so the package says product: 'ledger':

node pinned.mjs
the package's model says product: 'ledger', copied from the first project
  ledger   generated
  billing  generate failed
    [aontu/scalar_value]: Cannot unify values at path $.main.kit.feature.acmeheaders.config.options.product
    Cannot unify value: "ledger" with value: "billing"

The model language, aontu, unifies two concrete values only when they are equal, so the package’s value and the project’s collide. Replace the value with a default.3

package check reports model-parse and Cannot resolve value on an option declared as a bare type such as product: string. Give it a default, as shown earlier, and check in the project’s tests that the value was set.

package update stops and names a file. A project edited its installed copy, and the update will not overwrite an edit without --force; a test pins that. Move the change into the package, release it, and update again.

When not to do this

Do not package a customization before the third project needs it. A package is a contract with a manifest, a version in two files, and consumers to upgrade, which costs more than copying a folder twice.

Do not put a project’s values in the package as values. Anything one project decides belongs in that project’s model/project.aontu, with at most a default in the package.

Do not use an sdkgen package to share code with SDKs that sdkgen did not generate. It installs into an sdkgen project’s .sdk tree, and a client from another generator has nowhere to receive it.

Do not edit the installed copy in a project. doctor reports it as an edited template master and package update refuses to overwrite it, so the fix stays in one project, which is the problem the package was meant to end.

Last verified

Verified 2026-10-15 against Node 22.22.0, @voxgig/create-sdkgen 0.30.7, @voxgig/sdkgen 4.34.7, aontu 0.76.0 and typescript 7.0.2. Every output block is what the command preceding it printed. Copier and OpenAPI Generator were not run; their behavior is taken from the documentation linked on this page.

Footnotes

  1. Copier’s updating guide reads every Git tag in the template, orders them by PEP 440, and checks out the latest before it updates. PEP 440 is the Python packaging standard for version numbers, and it orders the tags of a template that generates Go or Rust all the same. The same page asks, in a box marked Important, that nobody edit .copier-answers.yml by hand. ↩︎ Back to text

  2. The bundled scaffold is itself a package. The authoring guide says so of ts/project/, manifest and all, and the package list output earlier on this page agrees: the ts target and the test feature in each project are recorded as installed from @voxgig/sdkgen 4.34.7. The CLI reference adds that the scaffold passes package check with no findings across 27 targets and 17 features. ↩︎ Back to text

  3. The aontu front page shows a default as tier: *1. CUE’s tour calls the same asterisk a preference marker: the starred element of a disjunction is used when a value is required and nothing more specific arrives. The ledger project generated with the pinned value only because it supplied the same string. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.