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

Source: https://voxgig.com/howto/build-a-repl-for-your-api-in-node

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-18

## 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`](https://nodejs.org/api/repl.html#replstartoptions) takes the prompt, the
streams, and a `writer`, and
[`defineCommand`](https://nodejs.org/api/repl.html#replserverdefinecommandkeyword-cmd) 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](/howto/preload-a-generated-client-into-a-node-repl).
This page starts where it stopped.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [`ink`](https://github.com/vadimdemedes/ink) | A full-screen, keyboard-driven view of the API, drawn with React components | A 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 REPL | You want to type an expression and read what it returned |
| [`node:repl`](https://nodejs.org/api/repl.html) | A line-oriented shell over a JavaScript client you already have, with completion and history built in | Every command, prompt, error format, and background failure is yours to handle for as long as the tool lives | Your commands are not JavaScript expressions at all |
| [sdkgen `go-cli` target REPL](https://voxgig.com/sdk) | The API is described in OpenAPI and you want the shell generated beside the SDK with no code of your own | A Go toolchain, a vocabulary of three verbs, `list`, `load`, and `update`, and only the entities the model took from the document | Your commands need JavaScript, or an operation the model has no verb for |
| [`seneca-repl`](https://github.com/senecajs/seneca-repl) | The thing you explore is a Seneca message service rather than an HTTP API | A TCP port on the service that runs whatever is sent to it, and an input language of messages rather than client calls | The 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](https://react.dev/) 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 `_`.

```js title="meterco-repl.mjs"
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`](https://nodejs.org/api/readline.html#rlsetpromptprompt) 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.

## Print API errors as one line and keep the stack for everything else

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.

```js title="meterco-repl.mjs"
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`](https://nodejs.org/api/repl.html#global-uncaught-exceptions) 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.

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

```bash
node session.mjs
```

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

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

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

```text output
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](https://nodejs.org/api/cli.html#--unhandled-rejectionsmode).[^2]
Register an
[`unhandledRejection`](https://nodejs.org/api/process.html#event-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.

```bash
node pitfall.mjs
```

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

## Related how-tos

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

- [Turn an exploratory REPL session into a script](/howto/save-a-repl-session-as-a-script)

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

- [Design a CLI a coding agent can drive](/howto/design-a-cli-a-coding-agent-can-drive)

- [Generate a REPL from an OpenAPI description](/howto/generated-repl-from-an-openapi-description)

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

[^1]: The REPL catches what it evaluates with the [`domain` module](https://nodejs.org/api/domain.html),
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.

[^2]: The [`--unhandled-rejections`](https://nodejs.org/api/cli.html#--unhandled-rejectionsmode)
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.

[^3]: [`seneca-repl`](https://github.com/senecajs/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.