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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
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 | 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 | 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 | 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 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.
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.
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.
Related how-tos
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
-
The REPL catches what it evaluates with the
domainmodule, 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 -
The
--unhandled-rejectionsflag names five modes. The default,throw, is described as emitunhandledRejection, 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 -
seneca-repllistens 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