How-to › Ship a CLI or REPL

How to generate a REPL from an OpenAPI description#

Generate a shell from an OpenAPI document with the sdkgen Go CLI target, read what the model gave it words for, and point it at a sandbox before the first call.

Audience
API producer
Level
intermediate
Topic
Explore an API interactively
Languages
TypeScript, JavaScript and Go
Verified

Read first: Build a REPL for your API in Node.js

You own the OpenAPI document and the SDKs generated from it, and the question you get most is how to poke at the API without writing a program first. The REPL somebody started last year wraps the TypeScript SDK by hand, so it falls behind every time the document gains an endpoint. Its default base URL is production, because that is the first server the document lists.

What you get

You will end up with a shell generated beside the SDK, its verbs and entities taken from the model, built and driven against a sandbox stand-in by a script. This is for you if newcomers should type at your API before they write code.

Short answer

Scaffold an sdkgen project from the document with the go and go-cli targets and run npm run generate. The generated command is a CLI that becomes a REPL when run with no arguments, with list, load, and update over the entities the model extracted. It defaults to the document’s first server, so set METERCO_BASE to a sandbox and the key in METERCO_APIKEY before the first line.

You will need

Node 22 or later for the generator, Go 1.24 or later for the generated shell, and an OpenAPI 3 document. Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, @voxgig/create-sdkgen 0.30.8, @voxgig/sdkgen 4.35.0, and the boru engine v0.0.2-0.20260804163932-0d66b55c5110. The document on this page describes a smart-meter API with seven operations, a bearer token, and two servers: production first, then a sandbox. The REPL is the no-argument mode of the go-cli target, a Go program that consumes the Go SDK generated beside it, so the two targets go together. Every word the REPL runs is a real request, which is why a stand-in server answers them here.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
OpenAPI Generator plus a hand-written shellYou want the client in one of its many languages and a shell with exactly your commandsThe shell is yours to write and to keep in step with every regeneration of the client beneath itYou want the shell to follow the document without a hand in it
Postman collectionA team that clicks through requests in a GUI and shares them in a workspaceA graphical tool rather than a shell, with no piping or scripting, and a collection that ages away from the document unless it is regeneratedYou want to type at the API from a terminal
RestishAny OpenAPI document, with generated commands, shell completion, and profiles, and nothing of yours to generateOne process per command, so profiles on disk are the only state, and a vocabulary of the document’s operations rather than its entitiesYou want a stateful session or a shell shaped like your domain model
sdkgen go-cli targetYou generate SDKs with sdkgen and want a shell that moves when the model movesA Go toolchain, three verbs over the model’s entities, records printed in Go map order, and a default base URL that is the document’s first serverThe REPL needs verbs the model has no word for, or you have no sdkgen project

Voxgig maintains sdkgen. This page compares it with OpenAPI Generator, a Postman collection, and Restish.

The generated REPL is the only row where the shell itself comes out of the document. OpenAPI Generator gives you a client in the language you pick and leaves the shell to you, a program to maintain beside every regeneration. Restish gives you generated commands over any document in a minute, with profiles on disk as its only state and a process per command. A Postman collection is a GUI, which suits clicking and not piping. The sdkgen row pays for its shell with a Go toolchain and a three-verb vocabulary.

Scaffold with the Go SDK and the Go CLI targets

The scaffold takes the document and the targets and installs the toolchain into .sdk/, and npm run generate extracts the model with apidef and writes go/ and go-cli/. The run below happened once. The page’s checks do not repeat it, and what it wrote sits under generated/, pruned to the sources the build needs.

npx --yes @voxgig/create-sdkgen@0.30.8 meterco --def openapi.json --target go --target go-cli
cd meterco-sdk/.sdk && npm run generate && cd ../..

Read what came out before building it. inspect.mjs reads the entity model, the generated command, and the generated SDK’s config, and sets them beside the document.

node inspect.mjs
document: 7 operations, first server https://api.meterco.example/v1 (Production)
model: 2 entities
  invoice: list (GET /invoices), load (GET /invoices/{invoiceId})
  meter: create (POST /meters/{meterId}/readings, POST /meters), list (GET /meters), load (GET /meters/{meterId}), update (PATCH /meters/{meterId})
REPL words: list / load / update <query?> <entity>
REPL entities: invoice meter
REPL environment: METERCO_APIKEY, METERCO_BASE
default base in go/core/config.go: https://api.meterco.example/v1, the document's first server
reachable from the REPL: 5 of 7 operations
  not reachable: POST /meters (createMeter), in the model as meter.create but the REPL has no such word
  not reachable: POST /meters/{meterId}/readings (recordReading), in the model as meter.create but the REPL has no such word

Seven operations became two entities. Three of the model’s four verbs have a word in the REPL, and create does not, so POST /meters is in the model and out of reach from the prompt. The readings endpoint went in as a second point of create, which is the model’s reading of an action it had no better name for. A REPL cannot know more than the model does. The default base is the first server in the document, which here is production.1

Build the REPL and point it at a sandbox

Build the binary from the generated module, then give it two environment variables: the key in METERCO_APIKEY and the host in METERCO_BASE. The host is the only thing between a session and the production server.

export function buildRepl() {
  execFileSync('go', ['build', '-C', 'generated/go-cli', '-o', '../../meterco-cli', '.'], { stdio: 'inherit' })
  return './meterco-cli'
}

buildRepl runs the build on every call, so the driver never runs a binary an earlier generation left behind. Go’s build cache makes the call quick when nothing has changed.

The driver starts a stand-in sandbox on the loopback interface and drives the binary one line at a time. It sorts the keys inside each record before printing, because the REPL prints a record as a Go map and a Go map has no fixed order.2

const sortKeys = (line) =>
  line.replace(/\{([^{}]*)\}/g, (_, inner) => `{${(inner.match(/\w+:(?:'[^']*'|[^\s{}]+)/g) ?? []).sort().join(' ')}}`)
node repl-session.mjs
meterco> /help
words:    list / load / update <query?> <entity>
entities: invoice meter
meta:     /help  /quit
meterco> list meter
[{id:'mtr_8f2' serial:'SN-40199' state:'installed'} {id:'mtr_31a' serial:'SN-40200' state:'retired'}]
meterco> load {id:"mtr_8f2"} meter
{id:'mtr_8f2' serial:'SN-40199' state:'installed'}
meterco> load {id:"mtr_nope"} meter
  error: MetercoSDK: load: request: 404: Not Found
meterco> list invoice
[{amountCents:12950 currency:'EUR' id:'inv_2041' meterId:'mtr_8f2' status:'issued'}]
meterco> create meter
  error: [boru/undefined_word]: undefined word: create
  --> 1:1
meterco> /quit
[exit 0]
the sandbox stand-in at http://127.0.0.1:38579/v1 saw:
  GET /v1/meters with Bearer sk_sandbox_demo
  GET /v1/meters/mtr_8f2 with Bearer sk_sandbox_demo
  GET /v1/meters/mtr_nope with Bearer sk_sandbox_demo
  GET /v1/invoices with Bearer sk_sandbox_demo

Each line is one expression in boru, the language the generated command is built on, so list meter reads as a sentence.3 A string id travels in braces, as {id:"mtr_8f2"}. The 404 arrives as the SDK’s own error, with the status in it. create meter is refused as an undefined word before anything is sent, and the stand-in’s log at the end shows four requests, every one of them carrying the sandbox key to the sandbox host.

Check it worked

Five tests pin the result. The generated command dispatches on every entity in the model, and the SDK defaults to the document’s first server. A session pointed at the sandbox lists both meters and sends the key as a bearer token, and a verb the target has no word for is refused before any request. The fifth leaves a stale binary where the build writes and checks that the driver replaces it.

test('the SDK defaults to the first server in the document', () => {
  const base = /"base": "([^"]+)"/.exec(readFileSync('generated/go/core/config.go', 'utf8'))[1]
  assert.equal(base, doc.servers[0].url)
  assert.equal(doc.servers[0].description, 'Production')
})
node --test repl.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2927.852138

# pass 5 is the line to look for. The first run on a machine takes as long as a Go build.

When it goes wrong

The first call of a session goes to production. METERCO_BASE was unset or empty, so the SDK used the default it was generated with, the document’s first server. The stand-in below plays the live host: the same routes, a different key.

node pitfall.mjs
the sandbox key, sent to the live stand-in:
meterco> list meter
  error: MetercoSDK: list: request: 401: Unauthorized
meterco> /quit
the live stand-in saw:
  GET /v1/meters with Bearer sk_sandbox_demo
a string id without braces, then with them:
meterco> load mtr_8f2 meter
  error: load: unknown entity "mtr_8f2"
meterco> load {id:"mtr_8f2"} meter
{id:'mtr_8f2' serial:'SN-40199' state:'installed'}
meterco> /quit
requests the sandbox stand-in saw: 1

The request left and the host refused the sandbox key, which is the good outcome; a live key in the same session would have been answered. Set both variables in a wrapper script that is the only way the team starts the shell. The alternative is to list the sandbox first in the document you generate from, which changes the default of every generated surface at once.

load mtr_8f2 meter fails with unknown entity "mtr_8f2". A bare word after a verb is read as the entity, so the one-argument form took the id for an entity name. The bare shorthand suits a numeric id, and a string id goes in braces, which the second half of the output shows. The stand-in saw one request, from the second line.

The fields of a record change places between runs. list meter printed state first in one run and id first in the next, because the REPL prints a Go map and Go iterates a map in no fixed order. The data is the same; a transcript compared byte for byte is not, which is why the driver sorts the keys.

When not to do this

Do not generate a REPL for an API that is a set of actions. The go-cli target has three verbs, so an API whose operations are authorize, capture, and refund gets a shell that can list and load and cannot act. Those operations sit in the model as points of create, where the REPL has no word for them.

Do not adopt sdkgen to get the REPL alone. The REPL is a by-product of generating the SDK from the model. Without an sdkgen project, Restish gives you generated commands from the same document in a minute, with restish api sync to refresh them when the document changes.

Do not explore production with a generated shell. Every word is a request and the default host is the document’s first server. A wrapper that sets METERCO_BASE, and a sandbox key the live host refuses, are the two cheap guards, and the second one only holds while the keys differ.

Do not expect the shell to know what the document left out. A path the model reads as a second create stays a second create, and a response without a schema does to the model what the page on a first SDK with sdkgen shows. The fix is in the document or the model, never in the REPL.

Last verified

Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, @voxgig/create-sdkgen 0.30.8, @voxgig/sdkgen 4.35.0, and the boru engine v0.0.2-0.20260804163932-0d66b55c5110. Every output block is what the command preceding it printed. The scaffold and generate run is not repeated by the checks; its output is committed under generated/, pruned to the sources the build needs. The commands that build and drive the binary are re-run against a stand-in server on the loopback interface. No request reached a real API. The driver sorts the keys inside each printed record, which the REPL prints in Go map order; the lines are otherwise as printed. Restish and OpenAPI Generator were read from their documentation and not run.

Footnotes

  1. The specification describes servers as an array of Server Objects which provide connectivity information to a target server, and says nothing about which of several a tool should prefer. A generator that takes the first entry reads a preference into a list whose author may only have written production down first because it came to mind first. ↩︎ Back to text

  2. The Go specification says that the iteration order over maps is not specified and is not guaranteed to be the same from one iteration to the next. A record printed from a map shows its fields in a different order each run, which is the program behaving as specified. It is also the reason the driver on this page sorts the keys before anything compares them. ↩︎ Back to text

  3. The generated command parses each line as an expression in boru, which its repository calls a typed, word-based query language. A word takes its arguments where they are written, and the reference implementation is a single Go binary. That is why list meter reads as a sentence, and why load {id:"mtr_8f2"} meter carries its argument between the verb and the noun. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.