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:
- an explicit flag (
--endpoint ...) - an environment variable (
SVC_ENDPOINT) - a config file (
~/.config/svc/config) - 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: upgradedlines 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.


Leave a Reply