Technology July 20, 2026 12 min read

Your CLI's next user is not a human

#AI#Agents#CLI#Developer Tools#Go#Open Source

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.

Watching an agent guess

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:

$ deployctl push --env staging
Error: could not find target 'staging' (did you mean 'stage'?)

[agent] The environment name appears incorrect. Retrying with 'stage'.

$ deployctl push --env stage
Error: authentication required. Run 'deployctl login' or see
https://docs.example.com/auth for details.

[agent] The tool requires login. Attempting non-interactive login.

$ deployctl login --token $DEPLOY_TOKEN
Unknown flag: --token
Usage: deployctl login [--sso | --browser]

[agent] Login requires a browser. I will try passing credentials
via environment variable DEPLOYCTL_TOKEN, which some tools support.

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.

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.

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.

The contract

1. A schema command, so agents never guess syntax

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.

$ higgs schema classify
{
  "name": "classify",
  "summary": "Classify messages with Ollama and optionally apply labels",
  "args": [{"name": "mailbox", "required": false, "default": "INBOX"}],
  "flags": [
    {"name": "dry-run", "type": "bool", "description": "Preview suggestions without writing labels"},
    {"name": "apply", "type": "bool", "description": "Apply suggested labels to IMAP"},
    {"name": "limit", "type": "int", "default": 100},
    {"name": "workers", "type": "int", "default": 4}
  ],
  "stdout": "ndjson",
  "exit_codes": [0, 2, 3, 4, 5, 6, 7, 9]
}

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.

2. JSON on stdout, always; prose on stderr

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.

$ oura heartrate --sandbox --latest
{
  "data": [
    {
      "timestamp": "2026-07-05T00:00:00.000Z",
      "bpm": 60,
      "producer_timestamp": null,
      "source": "awake"
    }
  ],
  "next_token": null
}

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.

3. Typed error envelopes, so agents branch on kind, not parsed English

Every failure is the same shape on stdout, and nothing else is written to stdout for that invocation:

$ oura sleep --start bogus
{
  "error": {
    "kind": "usage",
    "code": 3,
    "reason": "bad_start_date",
    "message": "invalid --start \"bogus\": parsing time \"bogus\" as \"2006-01-02\": cannot parse \"bogus\" as \"2006\"",
    "hint": "dates must be YYYY-MM-DD, e.g. 2026-06-29"
  }
}

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.

4. Exit codes as an enum, mapped 1:1 to error kinds

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, so run oura auth login. Exit 6 is network, so retry with backoff. Exit 7 is ratelimit, so wait for the interval in the hint, then retry. Exit 3 is usage, so re-read the schema and fix the invocation. The kind in the envelope and the process exit code always agree, so a caller can branch on either one and get the same answer. Retries become deterministic instead of vibes.

5. Secrets in the OS keyring, never in the model’s context

An agent should be able to drive a tool that touches your email or your health data without the credentials ever appearing in a prompt, a shell history line, or a context window. The tool reads secrets from the OS keyring (macOS Keychain, Windows Credential Manager, Secret Service on Linux), with an AES-256-GCM encrypted file as the fallback where no keyring exists.

$ pass show proton/bridge | higgs auth login --username alice@proton.me --password-stdin
$ higgs auth status

The agent calls the CLI. The CLI resolves the credential out-of-band. The secret never transits the model. This is the rule I consider least optional: the other four make agents effective, this one makes them safe to run at all.

Proof, three times

A contract is a hypothesis until you ship it. I have now shipped it three times, in three unrelated domains, and it held each time.

higgs is a CLI for Proton Mail. It connects to Proton Mail Bridge over IMAP on 127.0.0.1 and classifies mail with a local LLM through Ollama, so everything runs on localhost: no cloud inference, no API keys, no telemetry, and the mail never leaves the machine. The flagship command streams one NDJSON prediction per message and ends with a summary line (on the wire each object is a single line; they are expanded here for reading):

$ higgs classify --dry-run --limit 20 INBOX
{
  "mailbox": "INBOX",
  "uid": 1842,
  "subject": "Your order has shipped",
  "from": "ship-confirm@amazon.com",
  "suggested_labels": ["Orders"],
  "confidence": 0.94,
  "is_mailing_list": false
}
{
  "type": "summary",
  "mailbox": "INBOX",
  "classified": 20,
  "errors": 0,
  "skipped": 0
}

My agent runs this loop unattended against hundreds of messages a week. It has never once had to parse an English sentence to know what happened.

ouracli is a CLI for the Oura Ring API v2. Its distinctive trick is a single endpoint registry that drives three surfaces at once: the CLI commands, the oura schema manifest, and an MCP server (oura mcp serve) that exposes every endpoint as a tool. One source of truth means the three surfaces cannot drift apart. It also has a sandbox mode that needs zero credentials, because Oura publishes a sandbox environment with realistic fake data, which turns out to be the perfect way to let an agent learn a tool safely:

$ oura sleep --sandbox --all
{
  "id": "daily_sleep-0-2026-6-29",
  "day": "2026-06-29",
  "score": 80,
  "contributors": { ... }
}
{
  "id": "daily_sleep-1-2026-6-30",
  "day": "2026-06-30",
  "score": 73,
  "contributors": { ... }
}
{
  "count": 7,
  "endpoint": "sleep",
  "ok": true,
  "type": "summary"
}

(Again one object per line on the wire, expanded here.)

okf is a vendor-neutral Go CLI for Google’s Open Knowledge Format, a plain-markdown format for data catalog knowledge. Google’s reference implementation needs Python, the Gemini API, and BigQuery; okf is one static binary that speaks JSON natively. Here the contract enables a fully autonomous documentation pipeline: an agent scaffolds a bundle with okf init, writes concept documents, and then uses the CLI as its quality gate. okf validate returns findings as JSON, the agent fixes each one, and re-validates until the exit code is zero:

$ okf validate ./bundles/mydb
{
  "error": {
    "kind": "validation",
    "code": 400,
    "reason": "validationError",
    "message": "broken link: [Users] -> users.md (concept tables/users not found)"
  }
}

No human in the loop unless something needs a judgment call. That sentence is the whole promise of the contract, delivered.

(Disclosure: higgs and ouracli are independent, community-built projects, not affiliated with or endorsed by Proton AG or Oura Health Oy.)

What surprised me

The NDJSON terminator matters more than the NDJSON. Streaming JSON is easy. Knowing the stream is finished is not. Both higgs and ouracli end every stream with exactly one {"type":"summary",...} line, and that terminator is how a caller distinguishes “pagination completed” from “the process died mid-stream.” A well-formed stream is never truncated. Even when pagination fails partway, ouracli still emits exactly one summary line, with ok:false and the error envelope embedded, rather than a second competing artifact on stdout. I did not appreciate how load-bearing that one line would be until agents started building retry logic on top of it.

The MCP server was nearly free. I did not set out to build an MCP server for ouracli. But once every endpoint lived in a registry with typed inputs and a described output shape, oura mcp serve fell out in an afternoon: the registry that generates the CLI and the schema manifest generates the MCP tool list too. If your CLI honors the contract, you already did the hard part of MCP without noticing.

Humans like JSON-first tools more than I expected. I braced for complaints about staring at JSON. Instead, --pretty for indented output plus jq turned out to cover almost everyone, and several people told me they prefer it: the output is greppable, diffable, and scriptable by default, and nothing important is trapped in formatting. Designing for the agent quietly made the tool better for the human.

When your agent writes Python, your CLI is missing a feature. My daily higgs workload is inbox zero: twice a day an agent drives the CLI until the inbox is empty. During one of those sessions I caught the agent writing a Python script to aggregate saved classification output and answer a simple question: which of these messages are not mailing lists? It was not misbehaving. It was routing around a gap. The classification data was already sitting in the local state database with no way to ask for it back. That one observation became a release: higgs state query for asking questions of past runs, --uid - so every command that takes a UID set can read it from stdin, a read-only verify command for auditing a mailbox against an expected set, and extract --apply-as-label for stamping extraction results back onto messages as labels. Now the workflow composes in the shell:

$ higgs state query INBOX --is-mailing-list false | higgs move INBOX Folders/Personal --uid -

Watch what your agent scripts, because every workaround is a feature request written in Python. This is also what separates a real agent tool from glue over an API. We already learned this lesson with MCP: a server that mirrors API endpoints one-to-one gives an agent nothing it could not get with curl, and those servers are worthless in practice. Curating actual workflows into the tool is what brings it to life.

Build one at your job

The three tools above are open source, but the contract earns its keep at work. For a compliance team I built a CLI wrapping Drata, so evidence collection and audit management could go agent-first instead of click-first. At Piedmont Global I built an agent CLI for Testmo, because its official MCP server sat in beta for far too long and the test management work could not wait for it.

Neither took longer to build than the manual process it replaced. That is the part I want to push on: you do not need an open source project to justify this. Most of us already spend the working day in a chat UI directing an agent. Every browser tab your job requires is a tool your agent cannot drive, and every internal system with an API is an afternoon away from being a CLI it can. Wrap your tools. Stop clicking through UIs on behalf of software that could be doing the clicking for you.

Steal this

None of this is proprietary, and none of it is hard. A schema command, JSON on stdout, typed error envelopes, enum exit codes, secrets in the keyring. Five rules, maybe a week of work on an existing tool, and your CLI goes from something an agent screen-scrapes to something an agent drives.

All three implementations are open source, Apache 2.0, and small enough to read in a sitting: higgs, ouracli, and okf. If you maintain a CLI, pick one rule, the schema command, and ship it this week. Your next user will thank you, in the only way it can: by finally calling your tool correctly on the first try.

Get the next post in your inbox

Short, practical notes on agent infrastructure, DevSecOps, and AI-native product work. No spam.

Powered by Buttondown.

Enjoyed this article? Check out more of my thoughts on startups and technology.

View All Posts