Designing CLIs for AI agents

TL;DR

For an agent, a well-built CLI often beats an MCP server — but only if you build it for a caller that reads output instead of looking at a screen. Below, the rules that make the difference: do X, because Y, or else Z.

Agents drive CLIs better than they drive MCP servers. An MCP client loads every tool's schema into the model's context up front, used or not — and it isn't a one-time cost: those tokens are a cache write on the first turn and a (cheaper) cache read on every turn after, so a server the agent never calls is pure waste — real money on the API, or a faster burn through your budget on a subscription. Anthropic found that writing code against those tools instead cut one task from 150,000 tokens to 2,000. A CLI, by contrast, costs nothing until you call it, and it composes — pipe one command into the next. Best of all it's verifiable: as Armin Ronacher puts it, once a command works it runs a hundred times with no further inference.

So the interface worth building is the CLI. But clig.dev's old default — humans first — has flipped: the agent is now often the primary caller (what Trevin Chow calls agent-native), and it reads your output rather than looking at it. That changes what "good" means:

Output

Send data to stdout and everything else to stderr — and switch to JSON when you're not on a terminal. An agent pipes your output straight into a JSON parser like jq, so a progress line or log wedged into that stream breaks the parse. Emit JSON automatically when stdout isn't a TTY — an interactive terminal, absent whenever output is piped or captured, which is exactly when an agent is calling (most runtimes expose an isatty() check; an explicit --json / --plain always overrides). A human on a terminal still gets a table.

Emit raw data, not a {"status":"ok"} wrapper — the exit code is already the status. (The exit code is the number a command returns to its caller — 0 for success — and the agent reads it separately from your output.) So wrapping success just encodes that same bit twice; print the object and let exit 0 mean ok. Wrap only when you must carry metadata like a cursor or total, and then wrap the data, not the status:

[ {…}, {…} ]                          # a bare list — just the data
{ "items": [ … ], "next": "cursor" }  # wrap only to carry a cursor / total

(My own croni wraps its success output in {"status":"ok",…} — redundant, and I'd change it.)

Keep output narrow by default. Every row costs the agent tokens, so a list that dumps ten thousand results is a budget leak: paginate, offer --compact, and on truncation emit a hint that teaches the next filter — otherwise the agent quietly works from half the picture.

Failure

Exit 0 on success, non-zero on failure — it's the first thing the agent checks, before it reads a byte. Get it wrong and nothing downstream can trust you; the worst case is a mutation that half-succeeds while still exiting 0, because the agent then builds on a lie. Keep it that simple to start — a small, stable taxonomy (2 usage error, 3 not-found, 4 auth) is a fine upgrade later, if you'll keep it documented.

Put a machine-readable error on stderr, and enumerate the valid set. Give the agent something to parse and act on, not prose to interpret:

{"error": "invalid --status", "allowed": ["queued", "running", "failed"]}

Enumerating the valid set lets it self-correct in one retry; a bare invalid status just sends it to parse --help and guess.

Interaction

Never block on a prompt. An agent has no terminal to answer [y/N], so a confirmation doesn't slow it down — it hangs the whole turn. Detect a non-TTY and fail fast (croni's remove refuses without --force) instead of waiting for input that never comes.

Make the destructive path opt-in, not the default. An agent retries blindly, so a command that deletes by default turns one transient error into repeated damage. For irreversible or high-blast-radius ops, require an explicit --force / --commit — a bare call should refuse and say how — and offer --dry-run to preview first.

One caveat: scale the guard to the blast radius. Cheap, reversible ops — an enable, a tag, anything idempotent (running it twice changes nothing) — should just execute; the conventional execute-by-default with an optional --dry-run is fine there. Guarding every mutation only costs the agent extra calls.

Make retries safe. Agents retry blindly on a timeout or a hiccup, and a human spots a duplicate where an agent won't — so a repeated create should return the existing resource, not a second one (a natural key or idempotency token gets you there). Return an identifier in every mutation response too, so the next call can reference what this one did.

Offer a --readonly mode. A CLI that can deploy or delete is safer to hand an agent if you can pin it to non-mutating commands — a --readonly flag (or a command allowlist) lets the caller lock the destructive paths off entirely, instead of trusting the agent to steer around them.

Discovery

Make --help complete and parseable, with real examples — agents copy examples verbatim. If they can't learn to call you from --help, they waste a round-trip guessing.

Ship one command that dumps the whole command tree as JSON — don't make the agent crawl --help on every subcommand. One call (mytool schema --json, or an agent-context) hands it every command, flag, and enum at once; generate it from the same source as --help so the two never drift. Without it, an agent maps your CLI one --help at a time — more calls, and a picture that can go stale.

Use the conventional vocabulary: get / list / create, and --json not --format. Agents generalize across every CLI they've seen, so a conventional tool is recognized on first contact; one that takes --format json where the rest of the world takes --json succeeds slowly, after wasted --help calls. Stay predictable elsewhere too: ship --version, honor NO_COLOR, be idempotent.

Building the CLI isn't the only side of this — sometimes you're wiring one into an agent. For that you need almost nothing: a single line in the agent's instructions file (CLAUDE.md, AGENTS.md) pointing at the CLI usually beats standing up an MCP server, and in Claude Code a Skill makes a set of commands discoverable without keeping their schemas in context. Reach for MCP when the consumer is an IDE with no shell, or you need a stateful or streaming session.

None of this is exotic — it's the old CLI wisdom re-centered on a reader who happens to be a machine. Build it in from the start and the same tool serves your agents, your scripts, and future-you. This is the foundation; for the deeper cut — async --wait and job ledgers, profiles, the skill and introspection layers — Trevin Chow's ten principles go further. I build dev and croni this way — one wrapper I'd take back and all. Tell me what I got wrong.

Tags

CLI AI Agents MCP Developer Tools