Generated, not take-it-or-leave-it

The reason teams fork generators is that customizing feels impossible. So we made customization the architecture: your repo holds the templates, the model holds your decisions, and your own targets and features are peers of ours.

Read the demo repo Read the how-to

The fork you are trying to avoid#

Every team that adopts a generator asks the same question first: what happens on the day the output is not quite right?

With most tools the answer is a fork. You eject, you patch the generated code by hand, and regeneration stops being safe. From then on every spec change is a manual merge, which is the exact maintenance tax the generator was supposed to remove.

Voxgig's answer is structural. Three things drive generation, and all three land in your repo when you scaffold a project. The model holds the type-safe description of your API and every decision your project makes about itself, from package names to active features, and regeneration re-reads it every time. Templates are the literal source copied into every SDK. Components are the generators that write the API-shaped parts. Your files, under your license. Customizing them is how the tool is meant to be held.

The rule underneath: regeneration must always be safe. Anything you own is declared in the model or added through an extension point, so running generate twice, or upgrading sdkgen, can never silently destroy your work. A verifier, voxgig-sdkgen doctor, enforces the rule in CI.

Six levers, from mild to wild#

In order of preference: many projects never need more than the first two, and the last three are how the generator itself is built. Each lever has a closer look below.

Model

Everything your project decides about itself is declared in .sdk/model/, written in aontu: a JSON-based specification language, easy to edit by hand and designed for building ontologies like this one. Files unify instead of overriding, so small declarations compose into one model. Most customization is a declaration here, not an edit anywhere, and regeneration re-reads it every time.

Templates

The literal source of each SDK, copied into your repo under .sdk/tm/: the transport, the base classes, the feature implementations. Read them, learn from them, edit them, and doctor tells you exactly what you touched and what a resync would do about it. Durable template changes ride your own package, or go upstream.

Components

TypeScript generators in .sdk/src/cmp/ that write the API-shaped source: entity classes, tests, READMEs. Add your own component alongside the stock ones and it becomes part of every regeneration. doctor reports it as additive, not as drift.

Features

A custom feature is a model file plus its source for each language, a file or a whole directory, whatever that target's layout takes, written against the same pipeline hooks the eighteen shipped features use. Your feature rides the same generated dispatch, the same options surface, and the same offline test corpus.

Targets

A target is an output product: a thing generated from your model, like the SDK in each language, the CLI, or the MCP server. Nothing in the generator knows a fixed list of them, so a target you add is a peer of the ones we ship. The demo below adds a Bash SDK, generated from the same model as the TypeScript one.

Packages

Targets and features do not have to come from sdkgen. An sdkgen package is the mechanism for external ones: install a third party's target or feature with package add, or publish your own for other projects to use. The bundled languages arrive the same way, so your package is on the same rails as ours.

The model#

The model is where your project describes itself: one set of declarations that every target and every feature reads.

It is written in aontu, a JSON-based specification language designed for building ontologies like this one. The syntax is JSON with the ceremony removed, easy to edit by hand, and files unify: every declaration, wherever it sits, merges into one consistent model, and a contradiction is an error rather than a silent override. Your API's entities and operations land in the model from your OpenAPI spec. Your decisions go in project.aon, a file the toolchain creates once and never overwrites.

# .sdk/model/project.aon, from the demo repo
main: kit: repo: path: 'voxgig-sdk/voxgig-elementdemo-sdk'
main: kit: target: ts: publish: version: '0.1.0'
main: kit: test: live: strict: true

Package names, release versions, module paths, which features are active, which entities generate: all declarations here, all re-read on every generate. This is the first lever to pull, because nothing declared in the model can be lost to a regeneration or an upgrade.

Templates#

Templates are the SDK source that is the same for every API: the transport, the base classes, each feature's implementation, the scaffolding around them.

They sit in your repo under .sdk/tm/, one tree per target, and they are ordinary source files. Generation copies them into the SDK with placeholder substitution, ProjectName becoming your project's name in whatever casing each spot needs, so one tree serves every project. Reading them is also the fastest way to understand exactly what you are shipping.

Editing one is editing your SDK's source, and it takes effect on the next generate. The caveat is durability: templates are vendored copies, and the next target add refreshes them from their recorded source. So doctor reports every edited template before a resync would revert it, and an edit you want to keep for good moves into your own package, or goes upstream as a contribution.

Components#

Components are the generators: TypeScript classes in .sdk/src/cmp/ that write the API-shaped parts of each SDK.

The line between the two layers is one question. Same for every API? Template. Depends on your API? Component. Entity classes, test suites and READMEs all depend on your entities, so a component writes each of them by walking the model.

Add a component of your own, a changelog writer, a docs page per entity, a manifest for your internal registry, and it runs on every generation beside the stock ones. doctor reports it as additive and a resync leaves it alone, because additions are the supported path. Fork a stock component instead and it is reported as forked, because the next resync would revert it: the same durability rule as templates, enforced by the same tool.

Features#

A feature is a cross-cutting behaviour, retry, caching, cost tracking, generated into every SDK that activates it.

A custom one is a model file that declares its name and options, plus its implementation for each language you care about: a single source file or a whole directory, whatever that target's layout takes. Implementations plug into the same seams the eighteen shipped features use: named pipeline hooks around each operation, or a wrapper around the transport itself.

What makes this more than a plugin system is the shared harness. Your feature gets the same generated dispatch, the same constructor options surface, and the same test treatment: behaviour cases are language-neutral data in .sdk/test/feature/, and every generated SDK runs them against its own implementation. Write the cases once, and every language proves them.

Targets#

A target is an output product: a complete artifact generated from the model, the way the TypeScript SDK, the CLI or the MCP server are.

The generator keeps no list of allowed targets. It walks the target index in the model, and every entry gets the same treatment, so a target you add behaves exactly like one we ship: target add installs it, generate emits it, doctor watches it.

Adding one takes three ingredients. A model file declares the facts every stage needs: the file extension, the comment syntax, the module name. A template tree carries the source that is the same for every API. Components write the API-shaped parts, typically the package skeleton, one file per entity, and the test suite. Generation phases can be switched per target, which is how the demo's Bash target keeps its README as a plain template instead of a composed one:

# ext/.sdk/model/target/bash.aon (demo repo, trimmed)
main: kit: target: bash: {
  title: 'Bash'
  ext: sh
  comment: line: "#"
  phase: {
    readme:     { active: false }
    agentguide: { active: false }
  }
}

Packages#

Packages are how targets and features travel. An sdkgen package is any folder with a sdkgen-package.json manifest beside a .sdk/ tree: a directory in your repo, a checkout, or an npm module.

package add installs everything the package provides and records where each piece came from. That provenance is what a resync reads: stock content refreshes from the sdkgen release you upgrade to, your content refreshes from your package, and neither can clobber the other. package list shows what is installed and from where; package check validates a package you are authoring before anyone installs it.

The demo keeps its package in-repo at ext/ and installs it by path. Publish the same folder to npm and another project installs it by name, custom target, custom feature and all. The bundled languages arrive through this mechanism too: sdkgen's own targets are a package, installed the same way yours is.

The worked example: a periodic table, in five languages and Bash#

Claims about extensibility are cheap. So we built the demo and published the repo: voxgig-elementdemo-sdk.

It is an SDK for a periodic-table API, elements, isotopes, groups and series, generated in TypeScript, Go, Python and Java. Then it customizes everything this page talks about, from inside its own repo, without touching sdkgen:

An entirely custom target: the same model that emits the four SDKs also emits a Bash library, curl for transport and jq for data, with one generated function per entity operation, an offline test mode, and a thin CLI.

$ source bash/elementdemo.sh
$ elementdemo_element_load id=fe | jq -r .name
Iron

$ echo '{"charge":3}' | elementdemo_element_ionize id=fe
{"ok":true,"ion":"Fe3+"}

A custom feature, elementcard, that renders any element-shaped result as an ASCII periodic-table tile. One model file, one implementation per language, hooked into the same pipeline stage in all five:

$ bash/bin/elementdemo element card id=fe
+---------+
|26     Fe|
|  Iron   |
| 55.845  |
+---------+

And the part that proves it: the feature's behaviour is asserted by language-neutral corpus cases in the project's own test data. The exact card string above is checked, byte for byte, by the TypeScript, Go, Python, Java and Bash test suites, from one file of cases.

# .sdk/test/feature/elementcard.aon
{
  name: 'renders an element-shaped result and records it'
  feature: [ { name: 'elementcard', active: true } ]
  res: [ { status: 200, body: {
    id: 'fe', name: 'Iron', symbol: 'Fe', number: 26, mass: 55.845 } } ]
  op: [ { op: '#OP1' } ]
  out: {
    count: 1
    last: "+---------+\n|26     Fe|\n|  Iron   |\n| 55.845  |\n+---------+"
  }
}

Both the target and the feature come from the repo's own sdkgen package, ext/, installed with one command:

cd .sdk
npx voxgig-sdkgen package add ../ext
npm run generate

Resync without regret#

The vendored copies in your repo are refreshed by target add and feature add, and refreshing is overwriting. That sounds dangerous, and unmanaged it would be. Two mechanisms make it safe.

Provenance. Every installed target and feature records where it came from. The stock ones resync from the sdkgen release you upgrade to. Yours resync from your own package. Neither can clobber the other, and a resync of the bundled TypeScript target puts your custom feature's source straight back where it belongs.

The merge. Generation itself is not a bulldozer. When a generated file already exists, the new content is diff-merged into it, three-way, so the generator's updates and local reality converge without you resolving anything by hand most of the time. Projects that prefer a clean slate switch to plain overwrite with one line of config, which is what the demo repo does.

The doctor. voxgig-sdkgen doctor answers one question: has this project drifted from what a resync would write? A modified component is reported as forked and a hand-edited template as edited, because the next resync reverts both. A component you added is reported as additive and left alone, because additions are the supported path. It exits non-zero, so CI can hold the line before a resync eats a change you cared about.

$ npx voxgig-sdkgen doctor
src/cmp/ts/Entity_ts.ts       forked     reverted by the next 'target add ts'
tm/go/core/transport.go       edited     reverted by the next 'target add go'
src/cmp/ts/Changelog_ts.ts    additive   yours, a resync leaves it alone

What this is, and what it is not#

What customization is

  • Declared in the model, or added through a supported extension point, so a resync can never silently revert it.
  • Verifiable. voxgig-sdkgen doctor reports every forked component, edited template and stale file, and exits non-zero for CI.
  • Portable. An sdkgen package carries your target or feature to the next project, with provenance recorded.
  • On the same rails as our own code. The bundled languages are an sdkgen package too, installed the same way yours is.

What it is not

  • Not editing generated output. Regeneration diff-merges over your edits by default, and projects can choose plain overwrite, so a hand edit there is at best temporary. Change the source of the code, not the code.
  • Not a soft fork. Hand-edit a vendored template and the next target add reverts it. That is what doctor exists to catch before it bites.
  • Not free of responsibility. A custom target is not covered by our cross-language parity suite until you cover it, and its manifest says so outright: parity: UNCOVERED.

FAQ#

If I edit a generated file directly, what happens?

By default, regeneration diff-merges. The generator three-way merges what it wants to write with what is in the file, so its updates and your edit usually both land without you doing anything, and a genuine conflict is marked rather than silently resolved. A project can opt for plain overwrite instead, treating output as fully disposable. Either way, a decision you want to keep for good belongs in the model, the templates or the components, where regeneration reads it rather than negotiates with it.

Can I add a language you do not ship?

Yes, and it is a first-class citizen when you do. A target is a model file (name, extension, comment syntax), a template tree, and three generator components: one for the package skeleton, one per entity, one for the test suite. The generator has no hardcoded list of languages. The elementdemo repo adds a Bash SDK in about six hundred lines, most of them Bash.

How do my changes survive an sdkgen upgrade?

Because adding is resyncing. target add and feature add overwrite the vendored copies from their recorded source: the newer sdkgen for stock content, your own package for yours. Anything you declared in the model is re-read on every generation. A hand-edit to a vendored file is the single case an upgrade can revert, and doctor reports it before you commit.

How is a custom feature tested across five languages?

The same way the shipped ones are. Feature behaviour cases are language-neutral data in your project's test corpus, and every generated SDK runs them against its own implementation. In the demo, the exact ASCII card string is asserted byte-for-byte in TypeScript, Go, Python, Java and Bash, from one .aon file of cases.

Why would I write a Bash SDK at all?

Mostly you would not, and that is the point of the demo: the target system does not care. curl plus jq is a perfectly good API client for CI jobs and ops scripts, and the generated functions beat hand-rolled curl one-liners the day the API changes. But the stronger argument is what it proves: if the model can drive Bash, it can drive your internal framework, your RPC layer, or the language we have not met yet.

Do I need to publish my customizations somewhere?

No. An sdkgen package is any folder with a manifest beside a .sdk/ tree. Keep it inside your SDK repo, point package add at the path, and it works. Publishing to npm is for when you want to share it between projects or teams.

Any target
the demo generates a Bash SDK beside TypeScript, Go, Python and Java
One corpus
the custom feature is proven byte-for-byte in five languages
Drift-proof
doctor reports what a resync would revert, and gates CI
MIT
the templates you customize sit in your repo from day one

Generate it, then make it yours#

Scaffold a project, read the templates that landed in your repo, and change the first thing you disagree with.

Read the demo repoTalk to Voxgig

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.