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

Source: https://voxgig.com/howto/package-a-shared-generator-customisation

- Audience: platform-team
- Level: advanced
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://copier.readthedocs.io/en/stable/updating/) | 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](https://openapi-generator.tech/docs/customization/) | 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](https://github.com/voxgig/sdkgen/blob/main/docs/how-to/author-an-sdkgen-package.md) | 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.

```json title="packages/acme-sdkgen-headers@1.1.0/sdkgen-package.json"
{
  "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:

```aontu title="packages/acme-sdkgen-headers@1.1.0/.sdk/model/feature/acmeheaders.aontu"
  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:

```aontu title="projects/billing.aontu"
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](https://github.com/voxgig/sdkgen/blob/main/docs/reference/features.md)
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.

```bash
node share.mjs
```

```text output
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](https://developer.mozilla.org/en-US/docs/Web/API/Headers/append) 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`](https://docs.npmjs.com/cli/v10/commands/npm-install/)
`@acme/sdkgen-headers@1.1.0` and then
`voxgig-sdkgen package update @acme/sdkgen-headers --no-fetch`, as the
[CLI reference](https://github.com/voxgig/sdkgen/blob/main/docs/reference/cli.md) 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.

```bash
node --test share.test.mjs
```

```text output
# 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'`:

```bash
node pinned.mjs
```

```text output
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

- [Fail a build when a generator customization becomes a fork](/howto/audit-generator-customisations-for-drift)

- [Let a coding agent customize a generated SDK safely](/howto/customise-a-generated-sdk-with-an-agent-safely)

- [Scaffold a Python API client project with Cookiecutter](/howto/scaffold-a-python-project-with-cookiecutter)

- [Push one CI template change to five hundred repositories](/howto/push-one-ci-change-to-many-repositories)

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

[^1]: Copier's [updating guide](https://copier.readthedocs.io/en/stable/updating/) reads every Git
tag in the template, orders them by [PEP 440](https://peps.python.org/pep-0440/), 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.

[^2]: The bundled scaffold is itself a package. The
[authoring guide](https://github.com/voxgig/sdkgen/blob/main/docs/how-to/author-an-sdkgen-package.md)
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.

[^3]: The [aontu](https://aontu.dev/) front page shows a default as `tier: *1`. [CUE's
tour](https://cuelang.org/docs/tour/types/defaults/) 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.