Toolchain documentation

sdkgen

The generator. One model in, an SDK per language out, each with the same behaviour and the same tests.

Turns the model into SDKs. The generator. It reads the model apidef produced and writes one idiomatic SDK per target language, each with the same operation pipeline, the same feature set, generated documentation and an offline test suite. Checked against the source repository, which is the authority where it and this page disagree.

What it is#

sdkgen is the second half of the toolchain. It does not read your OpenAPI spec: apidef does that, and hands sdkgen a model of entities, operations, fields and flows. sdkgen turns that model into code.

The unit of output is a target, and 23 of them are language SDKs. Twenty-two are bundled with the generator: TypeScript, JavaScript, Go, Python, PHP, Ruby, Lua, C#, Java, Kotlin, Scala, Swift, Dart, Rust, C, C++, Zig, Perl, Clojure, Elixir, OCaml and Lean. The twenty-third, Haskell, arrives as a package, @voxgig/sdkgen-haskell, which is how further targets arrive rather than as changes to the generator.

Four further bundled targets are consumer targets: they wrap a language SDK rather than call the API themselves. go-cli and go-mcp wrap the Go SDK to produce a command-line tool and an MCP server. py-data wraps the Python SDK for analysts. seneca-provider wraps the TypeScript SDK as a Seneca plugin. This is why one spec yields a CLI and an agent-callable server without anyone writing either.

Every target is generated from the same model through the same pipeline, so behaviour does not fork by language. A retry policy means the same thing in Rust as in Ruby because both were generated from one description of it, not written twice.

The generated code is yours, and regeneration is not a bulldozer: when a generated file already exists, the new content is three-way diff-merged into it, so the generator's updates and local reality usually converge without anyone resolving anything. A project that prefers a clean slate switches to plain overwrite with one line of config, which is what both demo repositories do. Either way a bug is fixed in the model, a template or a component rather than in the output, because a resync of a vendored target reverts hand edits and the merge is a convenience rather than a guarantee. The one file the toolchain will not touch is .sdk/model/project.aon, which is created once and is yours to edit.

Concepts#

The ideas you need to hold to use it, and the ones that cost time when nobody told you.

Targets, features and kinds

A target is something to generate: a language, or a consumer of a language. A feature is optional behaviour a generated SDK can carry, like retry or cache. Both are kinds, and the CLI's verbs are built from the kind registry, so docs is a kind too and voxgig-sdkgen docs add works without any dispatch code written for it. That is the seam a package like @voxgig/docgen plugs into.

Features are off until you switch them on

All 19 features are inactive by default. An SDK you have not configured behaves as if none of them existed: no retries, no cache, no logging, no measurable overhead. You activate one by name in the client options, and override its defaults there. Three are core (test, log, secrets), fifteen are enterprise capabilities (retry, timeout, ratelimit, cache, cost, idempotency, paging, streaming, proxy, telemetry, metrics, debug, audit, clienttrack, rbac), and one supports testing (netsim, which simulates network conditions).

Order matters for the features that wrap the transport

retry, secrets and timeout each wrap whatever transport is already installed, so the order you activate them in is the nesting order. A feature activated later wraps one activated earlier. This is why they are activated as an ordered list rather than an unordered map: a timeout inside a retry and a retry inside a timeout are different policies, and the model has to be able to say which you meant.

The CLI scaffolds, it does not generate

voxgig-sdkgen copies targets and features into a project's .sdk/ directory and registers them. It does not run code generation. Generation is driven by @voxgig/model through the programmatic API, which in a scaffolded project is npm run generate. Expect to lose time to this if you assume the CLI is the generator.

Entities, not endpoints

The generated surface is a small set of capitalised entities with the operations they support, not a method per URL. client.Moon().list({ planet_id }) rather than a path and a query string assembled by hand. The mental model stays the size of the API's data, not the size of its routing table, which matters for people and matters more for agents.

Every SDK ships an offline test mode

The test feature swaps the HTTP transport for an in-memory mock, so a generated SDK's unit tests run with no server, no network and no credentials. The mock starts empty and you seed it with the records the test needs. Every generated target also carries a readme_examples test that extracts each code block from its own README, compiles it and runs the runnable ones, so a documented example that does not work fails that target's build.

Examples#

Commands and API calls are as the component's own documentation gives them. Every example marked with a source is copied from that public repository, so it can be checked rather than trusted.

Add the targets and features you want

Run from the directory holding .sdk/. Names are comma-separated, so several arrive at once. target add copies the target model, its generator components and its templates, and registers it in the index; it also pulls in the test feature, because every target's generated suite depends on it. -y plans the work and writes nothing.

in <project>/.sdk
voxgig-sdkgen target add ts,py,go
voxgig-sdkgen feature add retry,timeout,secrets
voxgig-sdkgen -y target add rust        # dry run: log the plan, write nothing

npm run generate                        # this is what generates; the CLI does not

A target reference can also name a package, so voxgig-sdkgen target add @voxgig/sdkgen-haskell/haskell adds a language the generator does not bundle.

Call the API as entities

from solardemo

solardemo has two entities, and Moon is nested under Planet, so a moon call carries the planet_id it is nested by. Operations resolve to entities rather than raw records; .data() gets you the record inside.

solardemo, TypeScript
import { SolardemoSDK } from '@voxgig-sdk/solardemo'

const client = new SolardemoSDK()

// list() resolves to an array of Moon entities
const moons = await client.Moon().list({ planet_id: 'example' })

// load() returns the entity and throws on failure
const moon = await client.Moon().load({
  planet_id: 'example_planet_id',
  id: 'example_id',
})

// create() returns the created entity; the id comes off its data()
const created = await client.Moon().create({
  planet_id: 'example_planet_id',
  diameter: 1,
  id: 'example_id',
  kind: 'example_kind',
  name: 'example_name',
})

await client.Moon().remove({
  id: created.data().id!,
  planet_id: 'example_planet_id',
})

Configure a client that needs a server variable and features

from elementdemo

elementdemo's base URL is templated on an account_id, so server is required rather than optional. Features are named in the client options and are inactive until they appear there. Every option below has a default; naming one overrides only that one.

elementdemo, TypeScript
import { ElementdemoSDK } from '@voxgig-sdk/elementdemo'

const client = new ElementdemoSDK({
  apikey: process.env.ELEMENTDEMO_APIKEY,

  // Required: this API's server URL is a template over these.
  server: { account_id: '<account_id>' },

  feature: {
    // Defaults: 2 retries, 50ms to 2000ms, factor 2,
    // on 408, 425, 429, 500, 502, 503 and 504.
    retry: { active: true, retries: 3 },

    // Default 30000ms. Wraps the transport, so it wraps retry above it.
    timeout: { active: true, ms: 5000 },
  },
})

const elements = await client.Element().list()

retry and timeout both wrap the transport, so the order they are activated in decides which one sees the other's attempts.

The same SDK, in Python

from elementdemo

The model is the same, so the surface is the same; what changes is what each language calls idiomatic. Python returns records as dicts and raises on error, where TypeScript resolves entities and throws.

elementdemo, Python
import os
from elementdemo_sdk import ElementdemoSDK

client = ElementdemoSDK({
    "apikey": os.environ.get("ELEMENTDEMO_APIKEY"),
    "server": {
        "account_id": "<account_id>",
    },
})

for element in client.Element().list():
    print(element)

Test with no server and no credentials

from solardemo

test swaps the transport for an in-memory mock. It starts empty, so seed it with the records the test needs, keyed by entity name and then by id. This is what makes a generated SDK's own suite runnable in CI without a sandbox account.

solardemo, offline test in TypeScript
// Shape: { entity: { <entity-name>: { <id>: <record> } } }
const client = SolardemoSDK.test({
  entity: {
    moon: {
      test01: { id: 'test01', planet_id: 'example_planet_id', diameter: 1 },
    },
  },
})

const moon = await client.Moon().load({
  planet_id: 'example_planet_id',
  id: 'test01',
})

Pair it with netsim to make the mock fail on purpose: { netsim: { active: true, failTimes: 2, failStatus: 503 } } proves your retry policy rather than assuming it.

Write a feature the generator does not ship

from elementdemo

elementdemo carries a feature of its own, elementcard, which renders an element-shaped result as an ASCII periodic-table tile. It lives in the project's own sdkgen package under ext/, and it is declared in the model exactly the way a bundled feature is. It is shape-triggered rather than bound to an entity: any single-record result carrying number, symbol, name and mass gets a card.

.sdk/model/feature/elementcard.aon
main: kit: feature: elementcard: {

  name: key()
  title: "ASCII periodic-table tile for element-shaped results"
  version: '0.1.0'
  active: true
  base: '../ext/.sdk'
  package: '@voxgig-sdk/sdkgen-elementdemo-ext'
  transport: 'none'

  config: {
    # Off by default, like every optional feature.
    options: {
      active: false
      print: false
    }
  }

  hook: {
    PreResult: { active: true }
  }
}

transport: 'none' says this one does not wrap the transport, so it carries none of the ordering constraints retry and timeout do. The ext/ package supplies an implementation per language, which is why the same card renders from TypeScript, Python, Go, Java and Bash.

Reference#

The lookup tables. The first-party documentation below goes deeper on every row.

CLI actions

NameWhat it is
target add <ref>[,<ref>...]Scaffold language or consumer targets into .sdk/, and register them.
feature add <name>[,<name>...]Scaffold features into .sdk/.
docs add <name>Scaffold a documentation target, from a package that supplies the docs kind.
package add <pkg>Install everything an sdkgen package provides. --only <kind>:<name> narrows it, --alias <name>=<alias> renames.
package update <pkg>Refresh an installed package. --force overwrites locally changed files and lists what it discarded; --no-fetch uses the copy already installed.
package check <path>Validate a package. The one action that runs where there is no project.
doctorReport on the project. A non-zero exit is a finding to act on.

CLI options

NameWhat it is
--debug, -g <level>trace, debug, info (default), warn, error or fatal.
--dryrun, -yPlan the work and log it. Write no files.
--help, -h / --version, -vPrint usage or version, then exit.

The 19 features

NameWhat it is
Coretest (in-memory mock transport), log (structured logging), secrets (resolve the credential through a provider chain, and exchange a refresh token for short-lived access tokens)
Resilienceretry, timeout, ratelimit, cache
Correct writesidempotency
Large result setspaging, streaming
Observabilitytelemetry, metrics, audit, debug, clienttrack
Governance and costrbac, cost, proxy
Test supportnetsim, which simulates network conditions so a resilience policy can be proved

Where things live in a project

NameWhat it is
.sdk/model/project.aonYours. Created once, never overwritten.
.sdk/model/entity/*.aonThe entities: fields, operations, id, relations. Toolchain-derived and refreshed.
.sdk/model/target/*.aonOne per target, plus target-index.aon registering them.
.sdk/model/feature/*.aonOne per feature, plus feature-index.aon.
.sdk/model/flow/*.aonThe generated flows: ordered operation sequences with assertions.
.sdk/src/cmp/<target>/The generator components for a target.
.sdk/tm/<target>/The templates for a target.
ts/, py/, go/, ...Generated output. Three-way merged on each regenerate by default, overwritten outright in a project configured for it.

The rest of the toolchain#

  • apidef Turns a spec into a model.
  • create-sdkgen Scaffolds a project.
  • docgen Generates documentation targets.
  • apigen Not yet published.

The pipeline, the components, and the two worked examples

Read the generated code#

The toolchain is MIT and open, and the catalog holds 600+ generated SDKs readable without installing anything.

Voxgig SDK GeneratorTalk 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.