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

Source: https://voxgig.com/howto/design-a-cli-a-coding-agent-can-drive

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://clig.dev/), and the exit code discipline from the [POSIX
conventions](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html) 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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [AGENTS.md beside the CLI](https://agents.md/) | The tool is already good and agents need to know its conventions | A document that drifts from the tool unless something checks it | The tool itself is the problem rather than its discoverability |
| [An MCP server instead](https://modelcontextprotocol.io/docs/getting-started/intro) | Agents are the main consumer and you want typed tools with schemas | A second surface to ship and keep in step with the CLI | People use the CLI by hand as much as agents use it |
| [The clig.dev guidelines](https://clig.dev/) | Any CLI, as the baseline everything else builds on | Reading and applying them, which is an afternoon rather than a sprint | Nothing: this is the floor, not an alternative |
| [The sdkgen go-cli target](https://github.com/voxgig/sdkgen) | You already generate an SDK and want a CLI over the same model | A generated tool whose conventions are the generator's rather than yours | Your 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.

```js title="cli.mjs"
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.

```js title="cli.mjs"
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.

```bash
node demo.mjs
```

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

```bash
node --test cli.test.mjs
```

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

## Related how-tos

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

## Last verified

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

[^1]: Unicode keeps the box-drawing characters in a block of their own, U+2500 to U+257F, which
[Blocks.txt](https://www.unicode.org/Public/UCD/latest/ucd/Blocks.txt) names Box Drawing and
[UnicodeData.txt](https://www.unicode.org/Public/UCD/latest/ucd/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.

[^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
128. [POSIX](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_08_02)
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.

[^3]: Two has company and a rival. Every Bash builtin returns 2 for incorrect usage, which [the
manual](https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html) 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](https://man.freebsd.org/cgi/man.cgi?query=sysexits&sektion=3) 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.