Every time you want to poke at an API you open a REPL and type the same four lines. The import, the token, the client construction, and then a typo in the entity name. The exploration you wanted to do takes a minute to reach, and half the time the token came from the wrong environment because you pasted it from a terminal scrollback.
What you get
You will end up with one command that opens a REPL with the client built, the entities named, and a
help() that lists them. Tab completion then works from the first keystroke. This is for you if you
explore an API often enough to have retyped the setup.
Short answer
Start node:repl yourself and put the built client on its context, with each entity named at the
top level so tab completion offers the operations. Define the names as non-configurable, so a stray
assignment cannot replace one. Add a help() that prints what was preloaded, because nothing else
tells a newcomer what is in scope.
You will need
Node 22 or later, and a client library you can import. The context is the REPL’s own object, and awaiting a promise at the prompt works without a flag in current Node.1 The REPL options are where the input, the output, and the global-context decision are made.
Voxgig maintains sdkgen, whose REPL is one of the six surfaces it generates. This page compares that with a bootstrap you write and with a plain REPL over a typed fetch client.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A REPL bootstrap you write | Any client, and you want to choose the names and what help says | Twenty lines to maintain, and a file every contributor has to know about | The client already ships a REPL |
| An sdkgen-generated REPL target | You already generate the client and want the REPL to follow the model | Names and layout decided by the generator rather than by you | You want the REPL to expose your own helpers alongside |
| node —require with a preload | You want the plain node command to gain the context | A hook that applies to every process started that way, not only the REPL | The preload should apply to an explicit command |
| openapi-fetch in a plain REPL | A typed client where paths rather than entities are the interface | Typing the path on every call, which is more keystrokes than tab completion | You prefer entities with operations on them |
The choice is really between naming things yourself and accepting what a generator named. A generated REPL is free and consistent across languages, and the names in it follow the model. A bootstrap you write costs twenty lines and lets you add the two helpers your team actually reaches for, such as a pretty printer, or a fixture loader.
Whichever you pick, put the entities at the top level rather than behind the client object. Typing
api.meter.list() is three tab completions and meter.list() is one, and over a session of
exploration that difference is most of the value.
Name the entities on the context
Assign each one rather than only the client.
const context = {
api: client,
meter: client.meter,
invoice: client.invoice,
Keep api as well. A reader who wants to see the whole shape types Object.keys(api), and a helper
that takes the client wants the client rather than one entity.
Make the names non-configurable, which the sample does with defineProperty. A REPL session is full
of half-typed assignments, and meter = 1 at the wrong moment otherwise ends the session’s
usefulness without explaining why.
Print what was preloaded
A help() that writes rather than returns is the difference between readable and noisy.
help: () => {
output.write('api the whole client\n')
output.write('meter, invoice entities, each with list, load and create\n')
output.write('token the credential the client was built with\n')
},
Returning the text instead would print it as an escaped JavaScript string, with the newlines visible
as characters. Writing to the output stream prints what a person expects and returns undefined,
which the REPL shows and nobody minds.
Take the token from the environment, with the variable name in the help. A REPL that silently uses a credential from somewhere is a REPL that eventually talks to production.2
Check it worked
Feed a session into the REPL and read what it answered.
METERCO_TOKEN=sk_live_demo node repl.mjs < session.txt
api the whole client
meter, invoice entities, each with list, load and create
token the credential the client was built with
undefined
'sk_live_demo'
[ 'token', 'meter', 'invoice' ]
[ 'list', 'load', 'create' ]
[
{ id: 'mtr_8f2', serial: 'SN-40199', state: 'installed' },
{ id: 'mtr_31a', serial: 'SN-40200', state: 'retired' }
]
{ id: 'mtr_8f2', serial: 'SN-40199', state: 'installed' }
null
The two Object.keys lines are what tab completion is reading. Typing meter. at a real prompt
offers list, load and create, because they are properties of a named object rather than
strings inside a client somewhere.
The last two lines are one call that found a record and one that did not. Piping a file into the REPL is also how you test a bootstrap without a terminal, which is what the tests below do.
The undefined on the fourth line is worth noticing rather than hiding. help() printed three
lines and returned nothing, and the REPL says so. A help that returned its text instead would print
the whole thing as one escaped string.
node --test repl.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 201.709538
When it goes wrong
Tab completion offers nothing. The client is behind a promise, or the context was set before the
REPL started. Assign after start returns, and build the client before you assign it. Anything
asynchronous belongs in the bootstrap rather than on the context.
An await at the prompt returns a pending promise. An older Node, or a REPL started with the flag off. Check the version before rewriting anything.
The second REPL in one process fails to start. The bootstrap used the real global as its context. Give each server its own context, which also stops a session leaking names into the next one.3
The REPL starts silently with the wrong token. The bootstrap fell back to a default. Print the token’s source in the help, and refuse to start if the variable is unset, which is why the command in the previous section sets it. A default is worse than a crash here. A misspelled variable name gives you a REPL that starts, looks right, and fails on its first call. A REPL that cannot say where its credential came from is one nobody should point at a live account.
When not to do this
Do not preload a client that talks to production by default. An exploratory REPL invites
experiments, and a create typed to see what it does is a record somebody has to delete.
Do not add helpers until you have typed the same thing three times. A bootstrap full of conveniences nobody uses is a file people are afraid to change, and a bootstrap nobody edits stops matching the client within a release or two.
Do not treat the sdkgen REPL target as a reason to skip the bootstrap question. It gives you a consistent REPL per language, generated from the model, and the helpers your own team keeps retyping are still yours to add.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The flag is the off switch.
--no-experimental-repl-awaitwas added in v16.6.0 and exists to turn off top-level await in the REPL. That is the form a feature takes once it is on by default. The option that survives is the one that turns it off, and this one has kept the word experimental in its name. ↩︎ Back to text -
The demo token borrows a convention. Stripe’s keys begin with
sk_test_in a sandbox andsk_live_in live mode, withpk_andrk_for publishable and restricted keys on the same pattern. The first eight characters say which money a request touches. The prefix answers the question this section asks before the help does, provided somebody reads it. ↩︎ Back to text -
repl.starttakes auseGlobaloption, false by default, so each server gets a separate context unless asked otherwise. The documentation adds that thenodecommand’s own REPL sets it to true. The interactive REPL everybody knows is therefore the one configuration the library does not default to, which is a thing to know before copying its behavior. ↩︎ Back to text