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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| a Copier template repository | Project scaffolding in any language, where each project answers the template’s questions once | copier update merges template changes, and writes conflict markers where a project’s edits collide | The shared piece is runtime behavior inside a generated SDK |
| an OpenAPI Generator custom generator jar and template set | Your SDKs come from OpenAPI Generator and need logic that templates alone cannot express | A Maven build, a jar on every consumer’s Java class path, and a whole generator to maintain | You need to change one behavior, not the generator |
| sdkgen packages | Several sdkgen projects that need the same feature, target or template | A public contract: a manifest, a version in two files, and every consumer’s upgrade to support | Fewer 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.
Related how-tos
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
-
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.ymlby hand. ↩︎ Back to text -
The bundled scaffold is itself a package. The authoring guide says so of
ts/project/, manifest and all, and thepackage listoutput earlier on this page agrees: thetstarget and thetestfeature in each project are recorded as installed from@voxgig/sdkgen4.34.7. The CLI reference adds that the scaffold passespackage checkwith no findings across 27 targets and 17 features. ↩︎ Back to text -
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