# 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.

Source: https://voxgig.com/howto/generated-repl-from-an-openapi-description

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript, go
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://voxgig.com/sdk), 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](https://openapi-generator.tech/docs/generators/go/) | 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](https://learning.postman.com/docs/design-apis/specifications/generate-collections/) | 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](https://rest.sh/docs/getting-started/connect-to-an-api/) | 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](https://voxgig.com/sdk) | 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.

```bash title="generate.sh"
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.

```bash
node inspect.mjs
```

```text output
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.

```js title="repl-session.mjs"
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]

```js title="repl-session.mjs"
const sortKeys = (line) =>
  line.replace(/\{([^{}]*)\}/g, (_, inner) => `{${(inner.match(/\w+:(?:'[^']*'|[^\s{}]+)/g) ?? []).sort().join(' ')}}`)
```

```bash
node repl-session.mjs
```

```text output
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.

```js title="repl.test.mjs"
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')
})
```

```bash
node --test repl.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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`](https://rest.sh/docs/reference/api-management/) 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

- [Generate your first SDK with sdkgen](/howto/first-sdk-with-sdkgen)

- [Preload a generated client into a Node REPL](/howto/preload-a-generated-client-into-a-node-repl)

- [Derive a noun-verb command tree from an OpenAPI document](/howto/derive-a-command-tree-from-operation-ids)

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Build a REPL for your API in Node.js](/howto/build-a-repl-for-your-api-in-node)

## 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.

[^1]: The [specification](https://spec.openapis.org/oas/v3.0.3#openapi-object) 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.

[^2]: The [Go specification](https://go.dev/ref/spec#For_statements) 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.

[^3]: The generated command parses each line as an expression in [`boru`](https://github.com/boru-lang/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.