How-to › Ship a CLI or REPL

How to build a REPL for your API in Node.js#

Wrap an API client in its own Node REPL: environment in the prompt, dot commands for state, one-line API errors, and a session that survives a background failure.

Audience
API consumer
Level
intermediate
Topic
Explore an API interactively
Languages
TypeScript and JavaScript
Verified

Read first: Preload a generated client into a Node REPL

Your team explores the API from the plain node REPL. Every session starts with the same import and the same pasted token. The prompt gives no hint whether a call is about to hit sandbox or live, and an API error arrives as a ten-line stack trace. Then a background token refresh fails and the session vanishes with an hour of state.

What you get

You will end up with a REPL command for your API: client preloaded, environment in the prompt, and dot commands for tokens and environments. It prints API errors on one line and survives a background failure. This is for you if your team explores APIs by hand.

Short answer

Call repl.start from node:repl with your client on the context, a writer that prints an API error as its status and message, and defineCommand for .env and .login, the state that is not a JavaScript expression. The REPL catches what it evaluates; a promise rejected elsewhere in the process ends it, so register an unhandledRejection handler that prints and redraws the prompt.

You will need

Node 22 or later, and a client library or fetch wrapper for the API. Verified 2026-10-21 against Node 22.22.0. The sample stands in for the client with client.mjs: two environments with a token each, and a meter entity. Its calls fail with a status when the token is missing or the record is not there. repl.start takes the prompt, the streams, and a writer, and defineCommand adds the dot commands. Putting the client on the context so that tab completion offers its operations is the previous page in this series: preloading a generated client into a Node REPL. This page starts where it stopped.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
inkA full-screen, keyboard-driven view of the API, drawn with React componentsA React dependency and a render loop of your own, in exchange for the line-by-line evaluation, history, .load, and .save that make a REPL a REPLYou want to type an expression and read what it returned
node:replA line-oriented shell over a JavaScript client you already have, with completion and history built inEvery command, prompt, error format, and background failure is yours to handle for as long as the tool livesYour commands are not JavaScript expressions at all
sdkgen go-cli target REPLThe API is described in OpenAPI and you want the shell generated beside the SDK with no code of your ownA Go toolchain, a vocabulary of three verbs, list, load, and update, and only the entities the model took from the documentYour commands need JavaScript, or an operation the model has no verb for
seneca-replThe thing you explore is a Seneca message service rather than an HTTP APIA TCP port on the service that runs whatever is sent to it, and an input language of messages rather than client callsThe target is an HTTP API with a client library

Voxgig maintains sdkgen. It is one of four options here, not the recommendation. Voxgig also sponsors Seneca, so two of the four rows are its own.

Hand-rolling on node:repl gives you the commands you want and leaves you owning history, completion, and error formatting for as long as the tool lives. ink gives you a rendered screen built from React components and takes away the evaluate-and-print loop, so an expression typed at a prompt is no longer a thing a user can do. A generated REPL moves when the document moves and knows only what the document said. The go-cli target offers three verbs and the entities the model extracted, with no room for a helper of your own.

Name the environment in the prompt and keep state in dot commands

The prompt is where the environment belongs, because it is the one thing on screen before every call. .env switches the base URL, drops the token, and redraws the prompt. .login sets a token without making the token an expression whose value the REPL prints and stores in _.

server.defineCommand('env', {
  help: 'Switch environment, which clears the token: .env sandbox | .env live',
  action(name) {
    this.clearBufferedCommand()
    try {
      client.use(name.trim())
      this.setPrompt(prompt())
      output.write(`${client.env} is ${client.baseUrl}, token cleared\n`)
    } catch (err) {
      output.write(`${err.message}, known: ${Object.keys(ENVIRONMENTS).join(', ')}\n`)
    }
    this.displayPrompt()
  },
})

clearBufferedCommand and displayPrompt are the two calls the documentation asks a command action to make, so that a half-typed expression does not survive the command and the prompt comes back. setPrompt comes from the readline interface the REPL extends. The help string is what .help prints beside the command, which is the only discoverability a dot command has.

The meter entity and a read-only view of the client go on server.context as non-configurable properties, as the previous page did. A stray meter = 1 cannot take the entity away mid-session. The view has env and baseUrl but not use or login, so the environment changes only through .env, and the prompt always names it.

The writer option formats every value the REPL prints, and the REPL hands it errors as well, with Uncaught in front. An ApiError carries a status, so it prints as the status and the message. Anything else keeps the inspect output with its stack trace, because a TypeError at the prompt is a bug in the REPL or the client rather than something the API said.

const writer = (value) =>
  value instanceof ApiError ? `${value.status} ${value.message}` : inspect(value, { colors: false, depth: 4 })

The REPL reaches this writer for anything thrown by code it evaluated, awaited or not, because it runs that code inside a domain of its own.1 What the domain does not reach is the subject of the first failure mode below.

Drive it from a script and read the transcript

A REPL is tested by feeding it lines, and the order matters. Lines written all at once arrive before the awaited calls answer, so a transcript made that way interleaves prompts with results from earlier lines. session.mjs sends one line, waits for the prompt to come back, and sends the next. It also adds the typed line to the transcript, because a REPL whose input is a pipe echoes nothing.

const queue = [...lines]
const next = () => {
  if (done) return
  if (!/> $/.test(text)) return setTimeout(next, 5)
  const line = queue.shift()
  if (line === undefined) return input.end()
  text += `${line}\n`
  input.write(`${line}\n`)
  setTimeout(next, 5)
}
node session.mjs
meterco[sandbox]> meter
{
  list: [Function: list],
  load: [Function: load],
  create: [Function: create]
}
meterco[sandbox]> await meter.list()
Uncaught 401 no token for this environment, use .login <token>
meterco[sandbox]> .login sk_sandbox_demo
token set for sandbox
meterco[sandbox]> await meter.list()
[
  { id: 'mtr_8f2', serial: 'SN-40199', state: 'installed' },
  { id: 'mtr_31a', serial: 'SN-40200', state: 'retired' }
]
meterco[sandbox]> await meter.load('mtr_nope')
Uncaught 404 no meter mtr_nope in sandbox
meterco[sandbox]> .env live
live is https://api.meterco.example/v1, token cleared
meterco[live]> await meter.list()
Uncaught 401 no token for this environment, use .login <token>
meterco[live]> .env staging
unknown environment staging, known: sandbox, live
meterco[live]> .exit

Four things to read off that. The environment is in every prompt, so the live calls are visibly live. The 401 and the 404 are one line each, and the 401 says what to type next. .env live drops the token, so the first call after the switch fails with a 401 rather than sending a sandbox token to the live host. An environment the client does not know is refused, and the refusal lists the ones it does.

Check it worked

Eight tests drive the REPL through drive and read the transcript. The one below pins what an API error says and what it leaves out.

test('a call before login prints a status and a message, not a stack trace', async () => {
  const text = await drive(['await meter.list()'])
  assert.match(text, /^Uncaught 401 no token for this environment, use \.login <token>$/m)
  assert.doesNotMatch(text, /at REPL|node:internal/)
})
node --test repl.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 269.936962

# pass 8 is the line to look for. The other seven cover .login, .env and its refusals, a 404 that leaves the session usable, and the context. Names on the context cannot be reassigned, .env constructor is refused like any unknown environment, and api.use does not exist, so nothing switches the environment around the prompt.

When it goes wrong

The session dies with Error: token refresh failed on stderr and exit code 1, in the middle of an afternoon. The REPL’s error handling covers the code it evaluated. A promise rejected anywhere else, a refresh timer or a poller the bootstrap started, falls to Node’s default, which raises an unhandled rejection as an uncaught exception.2 Register an unhandledRejection handler in the bootstrap that prints the failure through the same writer and redraws the prompt, which is what meterco-repl.mjs does. Two sessions below get the same failure, and each is sent a line after it.

node pitfall.mjs
plain node:repl, a token refresh fails in the background
  exit code 1, stderr: Error: token refresh failed
  the line typed after the failure was answered: no
meterco-repl.mjs, the same failure
  exit code 0, stdout: background failure: 503 token refresh failed
  the line typed after the failure was answered: yes

The timing does not change the outcome. A sweep for this page fired the rejection at delays from zero to three hundred milliseconds, some while the REPL was awaiting a line. The plain session ended in every run.

The transcript of a piped file is out of order. node meterco-repl.mjs < session.txt prints the prompts and the dot command answers first and the awaited results after them, because readline delivers every line before the first await settles. Drive the REPL a line at a time, as session.mjs does, whenever the output has to be read or compared.

.env staging is refused. The client knows two environments and says so, which is the behavior to want from a command that moves credentials around. The check reads own keys only, so .env constructor, a name every object inherits, is refused too. Add the environment to ENVIRONMENTS rather than loosening the check.

When not to do this

Do not build this when the bootstrap from the previous page is enough. A preload file that names the client on the context gives you completion and await with nothing more to write. The binary here earns its keep only when state has to change mid-session: environments, tokens, and the failures that come with them.

Do not turn history on for a session that holds real credentials. With setupHistory, the .login line lands in the history file like any other line, token included, in plain text. The sample keeps history off unless METERCO_REPL_HISTORY names a file.

Do not reach for the sdkgen go-cli REPL to get the features on this page. It is a generated surface with three verbs and no code of yours to maintain, which is its point, and it suits a team that stays inside what the document describes. A dot command of your own is outside that.

Do not open the REPL on a network port. seneca-repl does, on the loopback interface, and its README says that anyone who can connect can run any code in the process.3 A REPL over an HTTP API has no such need, and standard input is the only port it should have.

Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed. The ink and seneca-repl rows were read from their documentation and not run. The sdkgen row was read from a go-cli REPL generated with sdkgen 4.35.0 and run in the same session; it does not run on this page. The history file behavior was checked under a pseudo-terminal, and the timing sweep was run; both are described rather than captured.

Footnotes

  1. The REPL catches what it evaluates with the domain module, whose own page files it under Stability 0, deprecated. The page adds that the module is pending deprecation and will be fully deprecated once a replacement API has been finalized. The deprecation notice dates from version 1.4.2. The replacement has been pending for the life of every Node release since, and the REPL that ships inside each of them still depends on the module. ↩︎ Back to text

  2. The --unhandled-rejections flag names five modes. The default, throw, is described as emit unhandledRejection, and if this hook is not set, raise the unhandled rejection as an uncaught exception. The default was a warning until version 15.0.0 changed it, so a REPL written against an older Node survived the failure this page guards against and printed a warning instead. ↩︎ Back to text

  3. seneca-repl listens on 127.0.0.1:30303 by default, and its README says that anyone who can connect can run any code in your process. That is an accurate description of every REPL, and an unusual thing to print on the front of one. ↩︎ 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.