How-to › Ship a CLI or REPL

How to design a CLI a coding agent can drive#

Give a CLI machine-readable output, exit codes that mean one thing each, and a confirmation rule that never depends on a terminal being attached.

Audience
API producer
Level
intermediate
Topic
Make a CLI usable by people, scripts and agents
Languages
TypeScript and JavaScript
Verified

An agent runs your CLI, gets a table with box-drawing characters, and parses the wrong column.1 The next command prompts for confirmation, the agent sends nothing because it has no terminal, and the process hangs until a timeout. The transcript shows a tool that worked perfectly by hand and cannot be automated at all.

What you get

You will end up with a CLI whose output is parseable, whose exit codes distinguish a bad request from bad usage, and which never waits for a keystroke. It stays pleasant to use by hand. This is for you if people are wiring your CLI into agents and scripts.

Short answer

Add a --json flag that covers output and errors alike, and give every failure a stable reason token beside its message. Reserve one exit code per kind of failure, so a caller branches without parsing prose. Never prompt: a write refuses without an explicit flag whether or not a terminal is attached, and the help is available as data.

You will need

Node 22 or later, and a CLI you can change. The conventions come from the command line interface guidelines, and the exit code discipline from the POSIX conventions that shells already assume.2

Voxgig maintains sdkgen. This page compares its generated CLI with hand-written tools, an MCP server, and the published guidelines.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
AGENTS.md beside the CLIThe tool is already good and agents need to know its conventionsA document that drifts from the tool unless something checks itThe tool itself is the problem rather than its discoverability
An MCP server insteadAgents are the main consumer and you want typed tools with schemasA second surface to ship and keep in step with the CLIPeople use the CLI by hand as much as agents use it
The clig.dev guidelinesAny CLI, as the baseline everything else builds onReading and applying them, which is an afternoon rather than a sprintNothing: this is the floor, not an alternative
The sdkgen go-cli targetYou already generate an SDK and want a CLI over the same modelA generated tool whose conventions are the generator’s rather than yoursYour CLI does more than wrap an API

The decision people actually face is whether to ship a CLI or an MCP server for agent use. A CLI already exists in most cases, works in any environment with a shell, and is scriptable by people too. An MCP server carries argument schemas, which removes a class of mistake an agent makes with flags, and it is another artifact to build and version.

Do the CLI work first either way. An MCP server over a tool that prompts and prints tables inherits both problems, and the fixes below are what a good server would need underneath.

Make the machine shape complete

--json has to cover the failures too, or half the interaction is still prose.

const fail = (code, reason, detail, json) => ({
  code,
  stdout: '',
  stderr: (json ? JSON.stringify({ ok: false, reason, detail }) : `error: ${detail}`) + '\n',
})

The reason field is the one that matters. A message is for a person and gets reworded. A token such as not_found is a value an agent can branch on. Keep both in the same document, so neither has to be inferred from the other.

Keep errors on standard error and payloads on standard output. A caller piping output into a parser then gets valid JSON on success and nothing at all on failure. That is a cleaner contract than a stream which sometimes carries an error object instead.

Never wait for a keystroke

Confirmation is a flag, not a prompt.

if (spec.writes && !parsed.values.yes) {
  if (isTTY) return { code: EXIT.needsConfirmation, stdout: `Retire ${rest[0]}? Re-run with --yes.\n`, stderr: '' }
  return fail(EXIT.needsConfirmation, 'confirmation_required', `pass --yes to ${command}`, parsed.values.json)
}

Checking whether a terminal is attached is fine for wording the message. It is not fine for deciding whether the command may proceed. A rule that relaxes when no terminal is present makes the tool loosest for unattended automation, which is where it needs to be strictest.

Give confirmation its own exit code. An agent that gets code 3 knows to re-run with a flag, which is a different response from a failed request, and both are different from a typo in the command name.

Check it worked

Drive the tool the way an agent does, then the way a person does.

node demo.mjs
driven by an agent, no terminal attached
list --json                    exit 0  {"ok":true,"data":[{"id":"mtr_8f2","serial":"SN-40199","state":"installed"},{"id":"mtr_31a","serial":"SN-40200","state":"retired"}]}
show a known meter             exit 0  {"ok":true,"data":{"id":"mtr_8f2","serial":"SN-40199","state":"installed"}}
show a missing meter           exit 1  {"ok":false,"reason":"not_found","detail":"no meter with id mtr_zzz"}
retire without --yes           exit 3  {"ok":false,"reason":"confirmation_required","detail":"pass --yes to retire"}
retire with --yes              exit 0  {"ok":true,"data":{"id":"mtr_8f2","serial":"SN-40199","state":"retired"}}
a command that does not exist  exit 2  {"ok":false,"reason":"unknown_command","detail":"no command named delete"}

Each of the four exit codes across the six calls tells the caller to do something different. Code 1 means the request was wrong, so try another id. Code 2 means the tool was used wrongly, so read the help.3 Code 3 means add a flag. Nothing here requires reading an English sentence.

The last two lines of the demo are the same tool with a terminal attached, and the refusal is identical. Only the wording changes: a person gets a question and an agent gets a token. A CLI whose safety rules depend on who is watching is a CLI nobody can reason about.

node --test cli.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 123.223116

When it goes wrong

An agent hangs on a command. Something is reading from standard input. Search for every prompt, and make each one a flag with a refusal when it is absent.

JSON output is interleaved with progress messages. A spinner or a log line went to standard output. Send everything that is not the payload to standard error. Progress output belongs there anyway, because a person watching still sees it and a pipe does not.

The agent retries a write it already made. A timeout hid a success. Give write commands an idempotency key or make them safe to repeat, and say which in the help.

Parsing breaks after a release. A field was renamed in the JSON shape. Version the machine output, and treat it as an interface with the same rules as your API.

When not to do this

Do not remove the human output. A CLI that only speaks JSON is worse for the person debugging at two in the morning, and they are the reason it exists. Two shapes of the same answer is the goal, not one shape that serves neither well.

Do not add --json to one command and call it done. Partial coverage is worse than none, because a caller writes the parser and then meets the command that has no machine shape.

Do not lean on the sdkgen go-cli target to make a tool agent-ready by itself. It gives every operation the same conventions, which is most of the battle, and the confirmation rules and exit code meanings for your own destructive commands are still yours to decide.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

  1. POSIX sets all three down under Exit Status for Commands, and adds that the shell reports the full eight bits through $?. A tool choosing codes of its own is choosing from what is left.

Footnotes

  1. Unicode keeps the box-drawing characters in a block of their own, U+2500 to U+257F, which Blocks.txt names Box Drawing and UnicodeData.txt fills with 128 names. Every one begins BOX DRAWINGS. Of the names, 78 contain LIGHT, 71 contain HEAVY, 33 contain DOUBLE, 12 contain DASH, and 4 contain ARC, which is how a table’s corners came to be round. The counts overlap, because a line can be heavy at one end and light at the other. The block exists so that a screen of text can draw a window around itself, and it does the job well enough that a parser walks into the wall. ↩︎ Back to text

  2. The shell keeps three answers for itself. A command that is not found exits 127, one that is found and is not executable exits 126, and one that a signal ended reports something greater than ↩︎ Back to text

  3. Two has company and a rival. Every Bash builtin returns 2 for incorrect usage, which the manual glosses as generally invalid options or missing arguments. BSD’s sysexits.h, which first appeared in 4BSD, gives the same failure the name EX_USAGE and the number 64. FreeBSD’s manual page for the header now calls the interface deprecated and discouraged. Its BUGS section reads, in its entirety, that the interface is not portable and the choice of an appropriate exit value is often ambiguous. ↩︎ 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.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.