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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| OpenAPI Generator plus a hand-written shell | You want the client in one of its many languages and a shell with exactly your commands | The shell is yours to write and to keep in step with every regeneration of the client beneath it | You want the shell to follow the document without a hand in it |
| Postman collection | A team that clicks through requests in a GUI and shares them in a workspace | A graphical tool rather than a shell, with no piping or scripting, and a collection that ages away from the document unless it is regenerated | You want to type at the API from a terminal |
| Restish | Any OpenAPI document, with generated commands, shell completion, and profiles, and nothing of yours to generate | One process per command, so profiles on disk are the only state, and a vocabulary of the document’s operations rather than its entities | You want a stateful session or a shell shaped like your domain model |
sdkgen go-cli target | You generate SDKs with sdkgen and want a shell that moves when the model moves | A 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 server | The 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.
Related how-tos
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
-
The specification describes
serversas 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 -
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
-
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 whylist meterreads as a sentence, and whyload {id:"mtr_8f2"} metercarries its argument between the verb and the noun. ↩︎ Back to text