You have an OpenAPI document and five languages of client to support. Hand-writing them means the
same retry logic five times and a drift you find in production. Most generators answer with one
method per endpoint, so GET /books/{id} becomes getBook and your users still assemble URLs in
their heads.
What you get
You will end up with a TypeScript SDK whose classes are the things your API is about, generated from your document by two commands. This is for you if you own an API and nobody wants to maintain its clients by hand.
Short answer
Run npm create @voxgig/sdkgen with --def pointing at your OpenAPI document and --target ts, then
run npm run generate inside the .sdk folder it wrote. The first command scaffolds and installs,
the second builds the semantic model and writes the SDK. Read .sdk/model/entity before you use the
output: it holds the classification every generated method comes from.
You will need
Node 22 or later and an OpenAPI 3 document. The document does not have to be perfect, but every operation needs a path and a method, and a schema on the response is what gives the generated types their fields. The OpenAPI 3.0 specification is the reference for what those pieces mean.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| sdkgen | You want entity-shaped clients and one set of features across languages | A semantic model to understand, and a smaller community than the alternatives | The API is a set of actions rather than resources |
| OpenAPI Generator | You want the widest language coverage and a large body of prior art | Endpoint-shaped Api classes, and per-language templates to maintain when you customize | You want one model to change rather than N template sets |
| Kiota | A Microsoft stack, and you like a fluent request-builder API | A path-shaped builder chain rather than named operations, and a runtime dependency per language | Your users want objects that read like your domain |
| A hand-written fetch wrapper | One language, a small API, and unusual ergonomics that matter | Every fix applied once per language, and the drift you get when one is missed | More than one language, or an API that changes |
Voxgig maintains sdkgen. It is the subject of this page, and its row in the table says when to pick something else. Read that one first.
The choice is really about what shape reaches your users. Generating from endpoints is a faithful transcription of the document. Extracting a model first is an opinion about what the document means, and an opinion can be wrong, which is why the section after next is about reading it.
Generate
Two commands. The first prompts when given no arguments, and every prompt has a flag, so this runs in a script or a CI job as easily as at a terminal.
npx --yes @voxgig/create-sdkgen@0.20.2 bookshelf --def api.json --target ts
cd bookshelf-sdk/.sdk
npm run generate
The scaffold writes bookshelf-sdk, copies your document to .sdk/def/, installs the project
toolchain into .sdk/node_modules and adds the target. npm run generate is the step that builds
the model and writes ts/. Re-run it after any edit under .sdk/.
Two folders matter afterwards, and they are not equal. .sdk/ is yours to edit: the model, the
project overlay, and any components you override. ts/ is output, rewritten on every generate.
Editing a file under ts/ works exactly once.
Read the model before you trust the SDK
The generated methods come from a classification, and the classification is a file you can read.
node inspect.mjs
document: 5 operations across 2 paths
GET /books listBooks
POST /books createBook
GET /books/{bookId} getBook
PATCH /books/{bookId} updateBook
DELETE /books/{bookId} deleteBook
model: 1 entity, 5 operations
book(author, id, published, title)
.create -> POST /books
.list -> GET /books
.load -> GET /books/{bookId}
.remove -> DELETE /books/{bookId}
.update -> PATCH /books/{bookId}
Five endpoints became one book entity with five named operations. load and remove share a
path and are told apart by method. The fields come from the response schema, so a property you
never documented is a property your users never get.
That is the whole argument for the model step. A caller writes book.load({ id }) rather than
getBook(id), and the next language target produces the same five names.
The classification is a guess, and it is a guess made from shape rather than from intent. A path
that ends in a variable segment and answers GET is a load; the same path under DELETE is a remove.
That heuristic reads a REST-shaped document well, which is what
the Richardson maturity model
calls level 2, and reads an action-shaped one badly. Nothing about the guess is hidden: it is in
.sdk/model/entity, in a format you can diff, and correcting it is editing that file rather than
arguing with a template.
Add a target with npx voxgig-sdkgen target add go and the same model produces a Go SDK with the
same five method names. That is the property that pays for the extra step, and it is the one you
lose with a per-language template set.
Check it worked
The assertion worth making is that nothing in the document went missing, and that the classification is the one you expected.
test('every operation in the document reaches the model', () => {
const inDoc = operations(doc).map((o) => `${o.method} ${o.path}`).sort()
const inModel = modelOps(model).map((o) => `${o.method} ${o.path}`).sort()
assert.deepEqual(inModel, inDoc)
})
test('five endpoints become one entity with the five entity operations', () => {
assert.deepEqual(
modelOps(model).map((o) => o.name).sort(),
['create', 'list', 'load', 'remove', 'update'],
)
})
node --test inspect.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
When it goes wrong
The first failure is an endpoint that is not one of the five. Add POST /books/{bookId}/checkout
to the document and regenerate, and there is no book.checkout().
node action.mjs
document adds: POST /books/{bookId}/checkout (checkoutBook)
model puts it on: book.create, not book.checkout
every path the model carries, by the operation that holds it:
book.create /books/{bookId}/checkout
book.create /books
book.list /books
book.load /books/{bookId}
book.remove /books/{bookId}
book.update /books/{bookId}
entity methods generated: create, list, load, remove, update
a checkout method: no
It is not dropped. It becomes a second point of create, reached with an $action key, and the
generated type says so:
export interface BookCreateData {
author?: string
id?: string
published?: number
title?: string
// Selects a custom action instead of the plain create:
// 'checkout'
// The remaining keys are that action's own payload.
$action?: string
[action: string]: any
}
A caller looking for the checkout call reads create and does not find it. Grepping the SDK for
checkout finds a comment. When an action matters to your users, name it in the model rather than
leaving it as a selector on a verb that means something else.
The second failure is editing under ts/. Everything there is regenerated, and the loss is silent
because the next generate succeeds. A change belongs in .sdk/: the model for a fact about the API,
the project overlay for a decision about this project, a component override for output shape.
The third is a document whose responses carry no schema. The paths still classify, so the SDK generates and looks right, and every field type comes out as an open object. Fix the document rather than the model, because the same gap costs you again on the next target.
When not to do this
Do not reach for sdkgen for an API with one consumer that you also own. Two repositories and a regenerate step is more machinery than a function that calls fetch. The model buys you nothing when there is one caller and one language.
Do not adopt entity generation for an API that is genuinely a set of actions. A payments API whose verbs are authorize, capture and refund is not three-quarters of a CRUD entity, and forcing it into one makes every call read wrong.
Do not generate into a repository your users have already forked. Regeneration overwrites ts/, and
somebody else’s edits live there now. Publish the package instead, and keep generation upstream.
Related how-tos
Last verified
Verified 2026-09-07 against Node 22.22.2, @voxgig/create-sdkgen 0.20.2 and @voxgig/sdkgen 4.8.3. The
three output blocks are what the preceding command printed. The scaffold and generate run itself is
not repeated on each build. Its output is committed under generated/: the entity model and the
generated types, from the run recorded here. The commands that read them are re-run.