The CLI Contract: building command-line tools an AI agent can actually drive

I hand a lot of my infrastructure chores to LLM agents now. Not because it’s trendy, but because a well-scoped agent with shell access is genuinely good at the boring 80%: “upgrade these machines,” “what’s the status of X,” “back this up before you touch it.” But the first time I pointed an agent at my own homegrown tools, it fell on its face. Not because the model was dumb, but because my CLIs were hostile to any caller that wasn’t a human staring at a terminal.

They prompted “Are you sure? [y/N]” and hung forever waiting for a keystroke that never came. They printed cheerful prose the agent couldn’t parse. They exited 0 even when the operation failed. They mixed error messages into the same stream as the results. Every one of those is fine for a human and fatal for a program.

So I wrote down a contract, a small set of rules every tool I build now follows, and the payoff surprised me: designing for the machine caller made the tools better for me too. Here it is.


The problem, concretely

Say you have a tool svc that manages some fleet of things. A human runs:

$ svc node list
web-01   running   2 cores   4G
web-02   stopped   2 cores   4G

Lovely. Now an agent wants to know which nodes are stopped so it can start them. It has to scrape that table: split on whitespace, hope a name never contains a space, guess which column is status. The moment you add a column or localize a word, the agent breaks. And when something goes wrong:

$ svc node start web-99
Error: no such node 'web-99', did you mean web-01?
$ echo $?
0

Exit 0 on failure. The agent thinks it worked. It moves on. Now you have a bug that only surfaces three steps later when nothing started.

These aren’t exotic problems. They’re the default behavior of almost every script that grew organically. The contract fixes them deliberately.


The contract

Seven rules. They’re not clever; they’re just consistent, and consistency is the whole point. An agent (or a human, or a shell script) should be able to predict how any subcommand behaves without reading its help.

1. Identical global flags on every subcommand

Every verb (list, get, start, upgrade, whatever) accepts the same set of global flags, spelled the same way:

--json        machine-readable output
--dry-run     show what would happen; change nothing
--yes, -y     confirm a destructive/mutating action
--quiet, -q   suppress human chatter
--verbose,-v  extra diagnostics (on stderr)
--help, -h    help for this exact subcommand

No -y on one command and --force on another. No --json that works on list but not on get. The caller learns the flags once.

2. --json means stdout is exactly one JSON value

Not “JSON with a friendly header line first.” Not “JSON, then a summary.” When --json is passed, standard output is a single parseable JSON value and nothing else. A consumer can do svc node list --json | your-parser and never think about it.

$ svc node list --json
[{"name":"web-01","status":"running","cores":2,"mem_gb":4},
 {"name":"web-02","status":"stopped","cores":2,"mem_gb":4}]

Human mode (no --json) can be as pretty as you like. The two are separate render paths over the same underlying data, never entangled.

3. Errors are one-line JSON on stderr, never on stdout

This is the rule that saves the most grief. Results go to stdout; problems go to stderr, as a single JSON object:

$ svc node start web-99 --json
{"error":"no such node: web-99","hint":"see: svc node list"}

…printed to stderr, while stdout stays empty. Why it matters: a caller can capture stdout as data with total confidence that a stray error string will never poison it. If stdout is non-empty, it’s a result. If the command failed, the reason is on stderr in a shape you can parse and surface verbatim.

4. Stable, documented exit codes

0 for success and “some non-zero” for failure isn’t enough. Give distinct codes to distinct situations so a caller can branch without string-matching:

0  ok
1  generic failure
2  usage / bad arguments
3  auth / permission / missing dependency
4  not found
5  refused: needs --yes
6  upstream / downstream system error

Now an agent can tell “you asked wrong” (2) from “the thing doesn’t exist” (4) from “I refused because you didn’t confirm” (5), and react appropriately instead of blindly retrying.

5. Never require a TTY. Ever.

No interactive prompts. A destructive action doesn’t ask “are you sure?” It refuses unless --yes was passed, and exits with code 5:

$ svc node destroy web-01
{"error":"refusing to destroy web-01 without --yes",
 "hint":"re-run with --yes, or --dry-run to preview"}
$ echo $?
5

This is the single most important rule for headless use. A prompt is a deadlock waiting to happen: the agent (or cron job, or CI pipeline) hangs forever on input that will never arrive. Replacing the prompt with a refusal plus a flag keeps the exact same safety for humans (you still can’t fat-finger a destroy) while making the tool safe to run unattended.

Pair it with --dry-run, which previews the plan and changes nothing:

$ svc node destroy web-01 --dry-run
{"dry_run":true,"action":"node.destroy","target":"web-01"}

The agent’s natural workflow becomes: dry-run to see the plan, show it to the human if needed, then re-run with --yes. No prompt in sight.

6. Config resolves flag → environment → file, with no interactive fallback

When the tool needs a setting (an endpoint, a token, a default target), it looks in a fixed order:

  1. an explicit flag (--endpoint ...)
  2. an environment variable (SVC_ENDPOINT)
  3. a config file (~/.config/svc/config)
  4. a built-in default, if one makes sense

And if it still can’t find a required secret, it fails with exit 3 and a hint. It does not prompt for it. Secrets come from the environment or a file, never a keyboard. This keeps the tool identical to run whether a human, a script, or an agent is calling it.

7. Diagnostics go to stderr, results to stdout, always

--verbose chatter, progress lines, “connecting to…” noise: all stderr. This falls out of rules 2 and 3, but it’s worth stating on its own. The invariant is: stdout is for the answer; stderr is for everything else. Hold that line and every pipe, capture, and redirect just works.


A worked example: “upgrade the fleet” done right

Rules are abstract. Here’s where they pay off. Suppose svc grows a verb to apt-upgrade a bunch of machines, the exact chore I was tired of doing by hand. A naive version would loop over hosts and run apt upgrade. A contract-compliant version looks like this:

Preview first (changes nothing):

$ svc fleet upgrade --all --dry-run
DRY-RUN: fleet.upgrade
  web-01: pending 12 pkg, backup -> daily-storage
  web-02: pending  0 pkg, skip (already current)
  db-01:  pending  4 pkg, backup -> daily-storage  [special: no auto-restart]

Notice what the dry-run alone tells you: what’s actually pending, where each backup will land, and which machines get special handling. If that plan looks wrong, you found out before touching anything.

Then commit, but it refuses without confirmation:

$ svc fleet upgrade --all
{"error":"refusing to upgrade 3 machine(s) without --yes",
 "hint":"re-run with --yes, or --dry-run to preview"}   # exit 5

$ svc fleet upgrade --all --yes
  [batch-a] web-01: upgraded (12 pkg)
  [batch-b] db-01:  upgraded (4 pkg); special: not auto-restarted
  web-02: current (no backup taken)
---
3 machine(s): 2 upgraded, 1 current

Baked into that one command are several habits the contract encourages:

  • Back up before you mutate. Each machine is snapshotted before the upgrade,

to wherever its routine backups already go, so a bad upgrade is one restore away.

  • Be idempotent / cheap to re-run. Machines with nothing pending are skipped

outright, with no backup and no work. If the run gets interrupted, you just run it again; the already-done ones cost seconds. This is what makes --all safe to fire off casually.

  • Stream progress to stderr. Those [batch-a] web-01: upgraded lines are

stderr, printed as each machine finishes. A long run stays observable, and the final JSON/summary on stdout is unaffected, and even if your capturing shell dies mid-run, the work completed and the per-machine progress already told you where it got to.

  • Know your special cases and be conservative with them. Some machines

shouldn’t be auto-restarted (a passthrough device, a stateful service, whatever). The tool detects them, treats them gently, and says so in the output rather than surprising you.

  • Don’t do the dangerous version by default. The everyday path takes the safe

action; the more disruptive variant (say, a kernel upgrade that needs a reboot) hides behind an explicit flag.

None of that is exotic. It’s just what “careful” looks like when you encode it into the tool instead of trusting yourself to remember it at 2am.


The punchline

I set out to make my tools legible to a robot. What I got was tools that are better for me: predictable flags I don’t have to re-learn per command, output I can pipe without grep | awk gymnastics, dry-runs that let me look before I leap, and destructive actions that can’t fire by accident but never nag me either.

Designing for the machine caller is really just designing for any caller who isn’t looking at the screen, which, half the time, is future-you in a hurry. The agent was the forcing function. The discipline is the reward.

If you build one tool this way, make it the one you’re most afraid of running. That’s the one where “preview, refuse-without-confirm, back-up-first, and tell me exactly what you did” stops being nice-to-have and starts being the reason you can sleep.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *