Your CLI's next user is not a human - Akeem Jenkins
Back to Blog<br>Every CLI ever shipped assumes a person at a keyboard. Help text written in English. Errors written in English. Exit codes that mean success or, vaguely, not. That contract is now wrong, because the fastest-growing user of command-line tools cannot read your mind and will not read your docs. It is a language model.<br>Watching an agent guess<br>Point an agent at a normal CLI and you can watch the failure mode in real time. The agent runs a command, the command fails, and the agent is handed a sentence written for a human. Here is a lightly compressed transcript of the kind of loop I used to see daily:<br>$ deployctl push --env staging<br>Error: could not find target 'staging' (did you mean 'stage'?)
[agent] The environment name appears incorrect. Retrying with 'stage'.
$ deployctl push --env stage<br>Error: authentication required. Run 'deployctl login' or see<br>https://docs.example.com/auth for details.
[agent] The tool requires login. Attempting non-interactive login.
$ deployctl login --token $DEPLOY_TOKEN<br>Unknown flag: --token<br>Usage: deployctl login [--sso | --browser]
[agent] Login requires a browser. I will try passing credentials<br>via environment variable DEPLOYCTL_TOKEN, which some tools support.<br>Nothing here is broken by human standards. The error messages are friendly. The suggestions are helpful. But the agent is doing archaeology: parsing prose, guessing flags that do not exist, and inventing environment variables based on what “some tools support.” Every step is a coin flip, every retry burns tokens and time, and the whole loop is one ambiguous error message away from doing something destructive. The tool is fine. The contract is wrong.<br>Credit where it is due: that transcript is my retelling of the viral example Justin Poehnelt used to introduce googleworkspace/cli, one CLI for all of Google Workspace, built for humans and AI agents. I stole the shape of his demo because it is the clearest illustration of the problem I have seen, and his project is the best evidence yet that the largest tool vendors are arriving at the same conclusion.<br>Over the past year I have shipped three CLIs built the other way around, where the primary caller is assumed to be a model and the human is the guest. The same five rules fell out of all three. This is the contract.<br>The contract<br>1. A schema command, so agents never guess syntax<br>The tool describes itself, completely, as JSON. One call and the agent knows every subcommand, flag, argument, output shape, and exit code. No parsing --help, no prompt-engineered command syntax, no screen-scraping.<br>$ higgs schema classify<br>"name": "classify",<br>"summary": "Classify messages with Ollama and optionally apply labels",<br>"args": [{"name": "mailbox", "required": false, "default": "INBOX"}],<br>"flags": [<br>{"name": "dry-run", "type": "bool", "description": "Preview suggestions without writing labels"},<br>{"name": "apply", "type": "bool", "description": "Apply suggested labels to IMAP"},<br>{"name": "limit", "type": "int", "default": 100},<br>{"name": "workers", "type": "int", "default": 4}<br>],<br>"stdout": "ndjson",<br>"exit_codes": [0, 2, 3, 4, 5, 6, 7, 9]<br>The manifest is generated from the same command tree the binary executes, so it cannot drift from reality. The CLI is its own tool definition.<br>2. JSON on stdout, always; prose on stderr<br>Stdout is for data. Stderr is for humans. Never mix them. A single result is one JSON document; a stream is NDJSON, one object per line. There is no English on stdout to parse heuristically, ever.<br>$ oura heartrate --sandbox --latest<br>"data": [<br>"timestamp": "2026-07-05T00:00:00.000Z",<br>"bpm": 60,<br>"producer_timestamp": null,<br>"source": "awake"<br>],<br>"next_token": null<br>Progress bars, warnings, and friendly narration all go to stderr, where a human can watch them and a pipeline can ignore them. One more thing worth doing on stderr: sanitize it. Strip ANSI escapes, bidirectional controls, and zero-width characters, because stderr gets fed back into a model’s context, and unsanitized text from untrusted sources is a prompt-injection vector.<br>3. Typed error envelopes, so agents branch on kind, not parsed English<br>Every failure is the same shape on stdout, and nothing else is written to stdout for that invocation:<br>$ oura sleep --start bogus<br>"error": {<br>"kind": "usage",<br>"code": 3,<br>"reason": "bad_start_date",<br>"message": "invalid --start \"bogus\": parsing time \"bogus\" as \"2006-01-02\": cannot parse \"bogus\" as \"2006\"",<br>"hint": "dates must be YYYY-MM-DD, e.g. 2026-06-29"<br>The agent branches on .error.kind. The message is for logs. The hint is written for the agent: a concrete next action, not a link to documentation. Rewording an error message is no longer a breaking change, because nobody is matching on the words.<br>4. Exit codes as an enum, mapped 1:1 to error kinds<br>Exit codes stop meaning “zero or not zero” and become a retry policy you can implement in five lines of shell, with no LLM in the loop. In ouracli: exit 2 is auth,...