Building Agent-Friendly CLIs

A human can work around a weird CLI: skim past the noise, decode a cryptic error, copy the UUID by hand. An agent can too — but you pay for every workaround in tokens, compute, and time — and raise the risk of the run going sideways. The fix isn't new. It's old Unix discipline, applied like the bill matters.

I build Cascade — an open-source platform that runs coding agents across the entire SDLC. Plan, split, implement, review, iterate, merge: a software factory, in other words. When you're operating agents at that scale, every CLI inconvenience cuts a thousand times. A mildly ambiguous flag becomes a thousand mildly ambiguous flags. A 200-token spinner becomes 200,000 wasted tokens. A vague error becomes a thousand extra LLM round-trips. So I've spent serious time making the CLIs my agents use as smooth as I can. This post is what I've found works.

What is a CLI?

CLI stands for Command Line Interface — but I like to think of it as an API shaped like a POSIX process. Like any API, it has inputs: an executable path, arguments, environment variables, file descriptors and a working directory. The outputs are stdout, stderr, side effects and termination status.

The CLI is essentially this POSIX process invocation:

pid_t pid = fork();

if (pid == 0) {
    execve(path, argv, envp);
    _exit(127); // execve failed
}

int status;
waitpid(pid, &status, 0);

CLIs have been around since the mid-60s, with the POSIX standard first established in 1988. If every POSIX executable exposes a CLI, then CLI is one of the most popular programming interfaces of all time.

What do agents have to do with CLIs?

Not every agent technically needs a CLI, but coding agents — and plenty of others — use them constantly.

Agentic harnesses ship their own built-in tools. Claude Code alone has Read, Write, Edit, Glob, Grep, web fetch, and subagents. But those cover generic file and web work. The moment an agent needs something project-specific — run your tests, hit your service, drive your deploy — it reaches for a CLI, or an MCP server. Even the richest harnesses fall back to the shell constantly.

When an agent runs code, tests, linters, typechecks, curl, or a custom script, that goes through a shell — often just labeled Bash in the harness UI — which then runs the actual CLI program with the right inputs and interprets the outputs. In between, the harness weaves in permissions, guardrails, and sibling-run orchestration.

Not everything finishes in one shot. A harness can run a CLI in the background — a dev server, a file watcher, a long build — and keep working while it runs, periodically polling for new output or watching the stream for a specific line: a port number, a test summary, a stack trace. The moment that line shows up, it can react. That's the other half of streaming output: a command that emits progressively, on the right stream, is one a harness can actually monitor — not just block on until it exits.

CLI or MCP?

Wrong question. They're not rivals so much as two surfaces over one core — and a good CLI is the better place to start.

A CLI is the universal substrate. Anything that can run a command and read the output can drive it: you at a terminal, a Makefile, a CI job, a cron line, any agent harness that can shell out. Its superpower is off-label use — pipe it, loop it, compose it into jobs its author never built a feature for. The shell turns one tool into a toolkit.

MCP is the opposite bargain: a typed, structured, machine-first contract — no shell-quoting, clean errors, native discovery — for exactly the operations you choose to expose. Ergonomic and predictable inside a harness, but closed: an agent gets the tools you defined, the way you defined them, and nothing you didn't. Whether a harness even speaks MCP is rarely the question anymore; most do. The question is the job and the environment: open-ended automation or a tight typed surface, and whether there's a shell to run in at all. No shell, no CLI.

What settles it: a well-built CLI wraps into an MCP server in an afternoon — a thin adapter over the same core. The reverse is a slog. So build the CLI first, and add MCP as a façade when a harness can exploit it. Every other discipline in this post — clean streams, structured output, stable IDs, honest exit codes — then pays off twice: fix it once, underneath, and both surfaces get better.

Does the agent care if the CLI is any good?

The agent might not, but you should.

Agents are driven by reasoning patterns learned from human-written and human-labeled data. They tend to assume tools work the way human developers expect them to. When that assumption holds, CLI use is smooth. When the CLI is hard to figure out, error-prone, slow, or noisy, it shows up in the speed of your SDLC, the quality of the work, and the token and compute bill.

Agents are supposed to help us do the mundane. As we offload more of that to them, CLI paper cuts compound across the whole workflow.

Raw start-up speed plays a part — a CLI that takes seconds to wake up stings when it runs a thousand times — but it's rarely the main event. The bigger, more fixable waste is the agent having to look twice, guess, and retry. That's what most of this post is about.

What is an agent-friendly CLI?

A CLI is agent-friendly when an agent can use it efficiently — minimal LLM back-and-forth, minimal wasted tokens, reliable interpretation of the result.

Throughout the rest of this post I'll ground the examples in ucho — a CLI I'm building for tasks, projects, and documents. Its commands follow a consistent noun-then-verb shape — ucho <entity> <action>, with flags after (ucho tasks list, ucho documents create, ucho projects get). The slice we'll lean on:

$ ucho --help     # projects, clients, tasks, documents, search, get, whoami, …
$ ucho tasks list [--project P-7K9] [--status todo,in_progress,done] [--due overdue|today|<date>] [--limit N] [--json]
$ ucho tasks create --title "…" [--project P-7K9] [--due <date>] [--json]
$ ucho tasks get <id> [--json]          # or any kind:  ucho get T-7K9-C --json
$ ucho documents create (--file <path> | --url <url>) [--project P-7K9 | --workspace] [--json]
$ ucho search "<query>" [--kind task|document|…] [--limit N] [--json]

Agent-friendly CLI design patterns

What follows isn't ranked. It's grouped by the path an agent takes through a CLI — discovering the surface, getting access, reading the output, handling identity and input, and dealing with failure. Take what's useful; skip what isn't.

Help

--help (or -h) is the de facto standard, and if your skills are missing or insufficient, the agent will reach for it to get oriented.

The nitty-gritty:

  1. Is the --help content always in sync with the actual code?
  2. Do you have a process that reflects relevant changes in the --help output?
  3. Is the output token-efficient, with a high signal-to-noise ratio?

CLIs with multiple subcommands are a natural fit for progressive discovery — don't dump everything at once. ucho --help lists the entities, ucho tasks --help lists what you can do to a task, and ucho tasks list --help spells out that one command's flags. The agent drills in only as far as it needs.

Pairing CLIs with agent skills

For agent-heavy workflows, --help is often not enough. It should explain the mechanics, not become a full operating manual for every workflow. That's where agent skills fit.

A skill is the agent-facing quickstart: common workflows, examples, output shapes, known footguns, recommended strategies. Keep stable facts and syntax close to the CLI; keep strategy in the skill. ucho tasks create --help should tell the agent which flags exist, what they mean, and what output to expect. The skill can say: when creating a task from a user request, first resolve the project, prefer --json, keep the returned ID, and avoid interactive mode.

The obvious risk is drift. If the implementation, --help and skill all describe slightly different CLIs, the agent will eventually hit the seam. Treat skills as part of the CLI surface: version them, review them with command changes, and test them by letting an agent actually use them.

Command structure and consistency

Agents are good at guessing patterns — so make the patterns worth guessing.

If your CLI has tasks list, tasks get, tasks update, then projects list, projects get, projects update should work the same way. The same flag should mean the same thing everywhere. If --project filters tasks in one command, the same concept should not be called --project-id, --projectId, or --project-name elsewhere. Stable IDs make good arguments, filters make good flags, and names, pluralization, and aliases should be boringly predictable.

This isn't about aesthetics — it's about reducing search space. The agent should be able to learn one corner of the CLI and transfer that knowledge to the rest.

Cross-cutting commands help here too. A global search, recent, get <id>, or whoami gives the agent a cheap way to orient itself when it starts with partial context.

Imagine ucho manages several kinds of objects: projects, tasks, documents, conversations, worklogs. If the agent only knows the user mentioned "pricing copy", it shouldn't have to guess whether that phrase belongs to a task title, a document body, a project note, or a conversation. A global search gives it one cheap first move:

$ ucho search "pricing copy" --json

Returning a mixed list with stable IDs and kinds:

[
  {
    "kind": "task",
    "id": "T-211-M",
    "title": "Update pricing copy"
  },
  {
    "kind": "document",
    "id": "D-82A-H",
    "title": "Pricing page draft"
  }
]

Only then does the agent move to the specific command — ucho tasks get T-211-M --json or ucho documents get D-82A-H --json.

This is different from a scoped command like ucho documents search "pricing copy", which is also useful, but only once the agent already knows it's looking inside documents. Global search is for orientation across the whole system; scoped search is for precision inside one object type.

Broad commands help orientation; specific commands do precise work.

Authentication

If your CLI is a thin client to a remote service, it needs some way to authenticate. Ideally, the user authenticates once (or periodically) and the agent uses the CLI from then on without dealing with it.

Regardless of the flow — password/token, OAuth — the result is usually a token that needs to be stored and the CLI needs to find it.

Environment variable

If you control the environment, the CLI can accept UCHO_TOKEN and pick it up automatically. Simple.

Downsides: the key lingers in every shell the agent spawns, mid-session token rotation is awkward, and in some setups (native UI apps especially) the environment is not convenient to control.

Credential file

Another option: keep credentials in a file like ~/.config/ucho/auth.json.

Pros: any process with read access picks it up — so if you log in via the CLI mid-session, already-running agents pick up the new token on their next call. Token expiration is easier at runtime — if the CLI has write access, it can refresh on its own. And auxiliary options (UCHO_BASE_URL and similar) live in the same place rather than scattered across env.

Credential store

Some CLIs avoid storing the secret in their own config at all and use the OS credential store: Keychain on macOS, Credential Manager on Windows, Secret Service / KWallet on Linux.

This is usually a good default for human-operated machines. The token is not plaintext on disk, the OS adds access control, and other desktop tooling already understands the model.

From the agent perspective, the question is not whether the keychain is "more secure" in the abstract — it's whether the authenticated CLI remains non-interactive when the agent uses it. If every ucho tasks list pops a permission dialog or asks for biometric confirmation, the CLI may be secure but it is not agent-friendly. The pattern that works: human logs in once, approves whatever the OS wants approved, and from that point the CLI reads the token without surprising the agent.

Combination

The nicest setup is often a small precedence chain: explicit --flag wins over env, env wins over config file, config file points to the keychain entry, defaults fill in the rest. Humans get a convenient login flow, agents and CI get an easy override path, and everyone gets a predictable answer to "which credential is this command actually using?" If you do this, make the order documented and inspectable — ucho whoami shows which token and base URL are actually in use.

Structured text output

We humans like shiny things: colored markdown, spinners, progress bars, rainbows. Some look cool, some help readability. None help agents. If they end up in context, they are mostly wasted tokens — and sometimes wasted back-and-forth when the "clarity" hides the one piece the agent actually needed.

LLMs understand unstructured data, so what's the big deal?

What I consider "good" output from the agent's perspective:

  1. Predictable — the agent knows the output shape before it runs the CLI.
  2. Consistent — similar shape across features.
  3. Clean — no ANSI colors, control sequences, warnings, or errors leaking through.
  4. Interpretable — pipeable into jq or a Python script, project-able to the fields it needs.

Consider this: the agent needs to find all overdue tasks assigned to the current user, group them by project, and produce a short summary.

If the CLI only has human output, the agent is forced to read and interpret a table:

$ ucho tasks list --due overdue

OVERDUE TASKS

ID         PROJECT      TITLE                         DUE
T-183-K    Website      Fix broken signup form        yesterday
T-211-M    Website      Update pricing copy           3 days ago
T-244-R    Internal     Renew staging certificate     yesterday

This looks fine to a human. For an agent, already annoying. Is 3 days ago a date? Is T-183-K enough to update the task later? What happens when the title wraps or abbreviates?

JSON to the rescue

JSON has become the de facto structured format. Other formats (TOML, YAML) are often better suited for their original tasks, but JSON wins on training-data prevalence, tooling ubiquity, and shell composability. That's why it's the primary agent-facing output format in almost all of my CLIs.

The structured version keeps the same domain query — only the output contract changes. The only difference in the ucho invocation is --json; the rest is shell composition:

$ ucho tasks list --due overdue --json   | jq '
      group_by(.project.name)
      | map({
          project: .[0].project.name,
          count: length,
          tasks: map({
            id,
            title,
            dueAt,
            url
          })
        })
    '

Which produces exactly the compact context the agent needs:

[
  {
    "project": "Website",
    "count": 2,
    "tasks": [
      {
        "id": "T-183-K",
        "title": "Fix broken signup form",
        "dueAt": "2026-05-29T12:00:00Z",
        "url": "https://ucho.example/tasks/T-183-K"
      },
      {
        "id": "T-211-M",
        "title": "Update pricing copy",
        "dueAt": "2026-05-27T12:00:00Z",
        "url": "https://ucho.example/tasks/T-211-M"
      }
    ]
  },
  {
    "project": "Internal",
    "count": 1,
    "tasks": [
      {
        "id": "T-244-R",
        "title": "Renew staging certificate",
        "dueAt": "2026-05-29T12:00:00Z",
        "url": "https://ucho.example/tasks/T-244-R"
      }
    ]
  }
]

Not every agent will write a beautiful jq program on the first try. The important part is that it can. The output has stable keys, real identifiers, machine-readable dates, no colors, no table wrapping, no "Loading..." line in stdout, and enough data to perform the next operation without another lookup.

Put the expected shape — and a few jq snippets — in your skills or even in --help, so the agent knows what to do from the start.

If the shape isn't reliable, the agent can't safely pipe it. It has to spend another LLM call looking at the unstructured output before it can decide what to do next.

Some CLIs make this even more explicit with field projection:

$ ucho tasks list --due overdue --json id,title,status,dueAt

Subtly different from piping everything to jq. The script pins the exact shape it depends on, and the CLI can enforce that contract. If status is misspelled or disappears, the command should fail with a usage error rather than silently returning a different shape. The full JSON format can grow additively, while scripts keep depending only on what they asked for.

What the agent does with jq here is, in effect, a free filtering feature. Add more scripting and you have the poor agent's GraphQL. When those scripts get complex and used often, that's usually a sign for a real CLI feature in its own right.

Output mode control

A --json flag throughout the CLI is a good first step and a convenient way to test structured output. It's also worth considering an env variable like UCHO_OUTPUT_MODE, so the agent doesn't need to remember --json in purely agentic environments. Leave it unset in your human shell; everyone gets the default they want.

A local config file (~/.config/ucho/config.json) works too if env variables aren't the right fit.

Pagination

A list command that silently truncates is a quiet, expensive failure. The agent asks for tasks, gets fifty rows, and assumes that's all of them. It decides. It moves on. It was wrong, and nothing told it so. No error. No exit code. Just a bad decision built on a partial view.

So every list command needs three things: a sane, documented default page size, a machine-readable signal that more results exist, and a clean way to fetch the next page — or all of them.

Cursor or offset

Offset pagination (--offset 50) breaks under concurrent writes. Insert a row mid-scan and page two skips one or repeats one. A cursor pins a position in a stable order, so you never skip or duplicate rows even while the data shifts under you. Agents run against live workspaces. Default to cursors.

Carry the "next" signal in the envelope

Put the signal in meta, right next to the data, so the agent checks one place every time.

{ "data": [ ... ], "meta": { "count": 50, "nextCursor": "eyJvIjo1MH0" } }

nextCursor is a string when another page exists, and null when you've hit the end. That's the whole contract — the agent loops until it's null.

$ ucho tasks list --limit 50
$ ucho tasks list --limit 50 --cursor eyJvIjo1MH0
$ ucho tasks list --all

--all drains every page for you. For commands that cap results instead of paginating, flip a meta.truncated boolean — same idea, one field to check.

One envelope shape. One field to test. The agent loops deterministically instead of guessing. Skip this and every list call is a coin flip the agent doesn't know it's flipping. At scale, that's a lot of coins.

Output stream hygiene

There are two primary text streams a CLI gets by default, and they should not mean the same thing.

stdout is the data stream. It's what gets piped into jq, redirected into a file, captured by the harness, or fed into the next command. If your command returns tasks, documents, search results, or JSON — that belongs on stdout.

stderr is for the things around the data: progress, warnings, debug, retries, deprecations, timing, diagnostics. Useful, but they should not pollute the stream the next program is supposed to parse.

This matters most when --json is involved. JSON on stdout must stay pure. Not "almost JSON", not "JSON after a warning line", not "JSON with a spinner before it". Just JSON.

Bad
$ ucho tasks list --json
Loading tasks...
[{"id":"T-183-K","title":"Fix broken signup form"}]
Good
$ ucho tasks list --json
[{"id":"T-183-K","title":"Fix broken signup form"}]

With Loading tasks... going to stderr, if it has to be printed at all.

The reason is boring but important: warnings mixed into stdout break jq, break scripts, break agent assumptions. The agent may be smart enough to notice what happened, but you've already paid for another reasoning step and probably another command run. Better to make the streams mean one thing each and keep it that way.

The agent can always use 2>&1 to merge them if it needs to.

Streaming output for long-running commands

A build. An ingest. A long sync. The lazy version buffers everything, then dumps it all when the command exits. The agent waits blindly. The user stares at a dead terminal. And then a wall of text lands at once, half of which becomes wasted context.

Don't buffer. Write the data to stdout progressively, as it happens.

For structured streams, use NDJSON — one self-contained JSON object per line. The agent or harness parses each result the moment it arrives, instead of waiting for one giant array to close.

Bad
$ ucho documents create --file ./docs/
# ...silence for 90 seconds...
[{"file":"a.md","status":"ok"},{"file":"b.md","status":"ok"}, ...]
Good
$ ucho documents create --file ./docs/
{"file":"a.md","status":"ok"}
{"file":"b.md","status":"ok"}
{"file":"c.md","status":"ok"}

Each line stands alone. Cut the stream at any point and what you already read is still valid JSON.

Keep the human progress beats on stderr — "processed 240 of 1000", "still working". They tell the user the thing is alive without polluting the stream the next program parses. stdout stays clean for the machine; stderr carries the running commentary.

The payoff is the whole thesis in miniature. The agent reacts to early results instead of blocking on the last one. The harness logs progressively. The user sees motion. Nobody pays for a giant terminal buffer they have to re-read later. At one invocation it's a nicety. Across thousands of calls, it's the difference between a CLI that scales and one that quietly burns tokens and latency on every call.

Colors and terminal control sequences

Colors are great for humans and mostly noise for agents. ANSI escape sequences, cursor movements, progress redraws, and other terminal control characters are not semantic output. If they leak into the harness observation, they become tokens. If they leak into JSON or tabular output, they make it unparseable and become bugs.

The rule is simple: if stdout is not a TTY, do not emit colors or terminal UI control sequences. If the CLI is writing into a pipe, file, test runner, CI job, or agent harness — assume the consumer wants plain text unless it explicitly asked for something else.

Boring explicit controls are worth supporting:

$ ucho tasks list --no-color
$ NO_COLOR=1 ucho tasks list
$ CI=true ucho tasks list

NO_COLOR is a nice convention because it composes across tools, much like CI=true does for non-interactive behavior. A --no-color flag is still useful when someone wants to force plain output regardless of detection.

The slightly more sophisticated version asks not only "is this a TTY?" but "what can this TTY actually do?" Terminal width, color depth, Unicode support, pager availability — capabilities, not universal truths. Good CLI frameworks already model this somewhere inside.

The agent-facing conclusion is simpler: don't make the agent look at your terminal art. A beautiful Ink, Bubble Tea or curses interface might be delightful for a human, but if it's the only way to operate the tool, the agent gets a hostile API. Keep the TUI (if absolutely necessary) as a human layer over commands that also work cleanly without it.

Returning enough information

Whether the CLI returns enough information for the next likely operation matters a lot in practice.

If the agent creates a task and the CLI only prints created, the next thing the agent has to do is usually search or list tasks to find the ID. One more command, one more LLM step, one more opportunity to pick the wrong object.

Bad
$ ucho tasks create --title "Update pricing copy"
Task created.
Good
$ ucho tasks create --title "Update pricing copy" --json
{
  "id": "T-211-M",
  "title": "Update pricing copy",
  "status": "todo",
  "url": "https://ucho.example/tasks/T-211-M",
  "createdAt": "2026-05-30T10:22:00Z",
  "updatedAt": "2026-05-30T10:22:00Z"
}

Same applies to updates, deletes, links, other mutations. If something changed, return the changed object — or at least its stable ID, URL, status, and timestamps. If the next likely action is to update it, link it, open it, or mention it to the user, include the fields needed to do that without a forced lookup-after-create.

This also keeps retries sane. Agents retry — a network blip, a timeout, an ambiguous failure. If a mutation returns the resulting object with its stable ID, and a failure returns an error the agent can actually read, the agent can tell whether the thing already happened. Without that, a retried create quietly becomes two tasks.

List and search commands follow the same rule. Don't show only names if the stable ID is what the next command needs. Humans like names. Agents need identifiers.

Short, speakable identifiers

Every example so far used IDs like T-211-M and D-82A-H, not database keys like 550e8400-e29b-41d4-a716-446655440000. That short, prefixed form is a deliberate second identity layered on top of the real one. I built it while working on voice — you can't dictate a UUID or have one read back to you over a call — but the bigger surprise was how much it helps even in plain text.

Three payoffs, none of them about voice:

  1. Context. IDs are everywhere in agent traffic: tool arguments, list results, follow-up commands, summaries. A UUID is long and tokenizes into mostly meaningless hex. A short ID is cheaper, and the prefix carries type information — T-7K9-C already tells the model it's probably a task.
  2. Referenceability. When the agent says "I've updated T-7K9-C", the user can hold it in their head, paste it into Slack, read it to a colleague, or use it in the next message. A UUID forces copy-paste every time.
  3. Local validation. Make the last character a checksum, and a mistyped or misheard ID fails before the CLI even hits the server. The CLI can say "that's not a valid task ID" instead of forwarding garbage and getting back a misleading "not found". For an agent, "you typed it wrong" and "it doesn't exist" are different next moves.

The details are worth stealing. Use an alphabet that's unambiguous to the eye and the ear — Crockford's Base32 drops I, L, O, and U, so nobody confuses 0 with O or 1 with l. Pick a checksum that catches the mistakes people actually make — a Damm checksum catches every single-character slip and every adjacent transposition. Size the random core to your scale: a couple of characters covers a thousand objects per type, and you let it grow from there.

One discipline keeps it safe: keep the real ID, add the short one, do not replace it. Internally everything stays a UUID; the short ID is a surface for users, agents, and CLI input — resolved back at the edge. In structured output, keep the canonical field scripts already depend on and add the short form beside it:

$ ucho tasks get T-7K9-C --json id,shortId,title
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "shortId": "T-7K9-C",
  "title": "Update pricing copy"
}

This is a bit of a sidequest, and it only pays off if you mint your own IDs. Wrapping a service that hands you UUIDs and nothing else? Skip it — a short ID you can't resolve back is just one more thing to keep in sync.

Large text inputs

A boring feature that pays off fast: let big inputs come from a file instead of the command line — either a dedicated --file <path> flag or an @path prefix that any body-like flag accepts.

Inline flags are fine for short strings:

$ ucho tasks create --title "Update pricing copy"

But they're a bad fit for agent system prompts, Dockerfiles, markdown bodies, SQL snippets, or long descriptions. Shell quoting gets subtly wrong, especially once newlines, quotes, backticks, and $VARIABLES show up.

So for anything multi-line, hand over a path and let the CLI read the bytes itself — no shell-escaping in between:

$ ucho documents create --file ./deploy-notes.md --project P-7K9
$ ucho agents update writer --system-prompt @./prompt.md

The rule for agents is simple: inline strings for one-liners, a file for anything multi-line. Write the content to a temp file, pass the path, avoid the shell quoting circus.

And it barely costs anything extra. The agent picks the temp path, so it can write the file and run the command that reads it in the same response — no waiting on the write before deciding the invocation, no extra LLM round-trip. There's no think-write-wait-think-run cycle; the agent plans both moves at once.

Exit codes

Process exit codes are the simplest output a CLI can emit and one worth making clear and consistent across every action the program performs. They matter at the LLM level and at the harness level, which might use them to gate sibling runs or subsequent tool calls.

If the agent runs the CLI inside a shell control structure — a loop, &&, ||, a script with set -e — the exit code is not a comment. It is control flow.

The universal part is very small and very strong:

  1. 0 means success.
  2. Non-zero means failure.
  3. In practice the value is an 8-bit status, so the useful range is 0..255.
  4. 128 + n is conventionally used for a process terminated by signal n, so 130 is SIGINT, 143 is SIGTERM, and so forth.

That foundation is the part you should not be creative about — follow it.

After that, things get less standardized than one might expect. Distinct non-zero codes can carry useful signal, as long as the taxonomy stays small and stable. The scheme I use is five buckets: 0 success, 1 a recoverable user error (validation, not found, conflict), 2 a usage error (a bad flag or missing argument), 3 auth (missing or rejected credentials), and 4 network (the service was unreachable). That's enough for an agent to act on the category before it reads a word — retry on 4, re-authenticate on 3, fix the invocation on 2 — while the readable message on stderr still carries the detail. Resist inventing thirty codes nobody will remember.

Making errors helpful

Errors are another place where CLIs often optimize for being technically correct without being particularly helpful. unknown option or not found beats crashing, but it still leaves the agent (and a human) guessing what to try next.

When the mistake is likely obvious, the CLI should say so. If the agent runs ucho taks list, suggest ucho tasks list. If it passes --proejct instead of --project, say which flag was probably intended. Levenshtein distance, or any fuzzy matching good enough for command and flag names, is cheap and pays for itself fast.

The same applies to domain objects. If ucho projects get websiet fails and there's exactly one close match called Website, say that. Maybe even show the exact follow-up command.

For an agent, every vague error becomes another reasoning step, another tool call, another opportunity to go sideways. A helpful error message explains what went wrong and makes the most likely next correct action obvious.

Show local help on usage errors

A related trick: when the command shape is wrong, show the relevant local usage immediately.

If the agent runs ucho tasks update --statuz done T-211-M, don't just say unknown option: --statuz. Say that, suggest --status if obvious, and show the short usage for ucho tasks update. The agent shouldn't need to run a second --help to learn the syntax it already failed to use.

This doesn't mean dumping the whole manual on every error. Usually the useful thing is the nearest command usage, the relevant flags, maybe one example. Exit non-zero, but make the next action obvious.

Interactive modes

Many human-friendly CLIs offer an interactive mode where the user gets asked questions rather than needing to know which --flags and arguments to use.

Convenient for humans, not optimal for agents. The harness can theoretically read the interactive prompt from stdout and send characters to stdin, followed by Enter, EOF, or the equivalent of Ctrl+C — but it's often not well supported, and even when it is, every interactive back-and-forth costs an LLM request. Imagine a beautiful multi-select input controlled by arrow keys and space. The agent has to send each arrow, wait for the repaint, read it, send more. Not great.

Battle-tested solutions:

  1. Don't ask questions when the process clearly isn't interactive. The first signal is whether stdin is a TTY — but don't lean on it alone. tmux, screen, and some CI runners hand you a TTY-shaped descriptor even when no human is watching. Combine signals: stdin isn't a TTY, CI=true is set, there's no usable terminal width. When in doubt, assume nobody's there. Either proceed with safe defaults or fail fast with a message naming which flag is required.
  2. For destructive or ambiguous operations, make the confirmation explicit in the invocation rather than interactive in the session. --yes, --force, --no-input, --non-interactive, --dry-run — boring flags, but boring is good here. The agent can reason about them before running, the command can be copied into a script, and the harness doesn't need to pretend to be a terminal user.
  3. The pattern I like: interactive by default for humans in a real TTY, non-interactive and explicit everywhere else. If the command would normally ask "Are you sure?", in agent mode it should say refusing to delete project without --yes and exit non-zero. Much better than hanging forever waiting for input the agent cannot reliably provide.

Ask the agent what hurt

A simple way to improve agent-friendliness: actually run the CLI through an agentic harness, then ask the agent to analyze the session log and report the snags it hit.

Give Claude Code, Codex, OpenCode — or whatever you use — a realistic task. Let it solve it with your CLI and accompanying skills. After the run, ask it to review the session: where did it need an extra lookup, where did parsing hurt, which flags were missing, which errors were vague, which output was too noisy, which command name was hard to guess.

The output won't always be right, but the pattern is useful. If several sessions produce the same complaint, you probably found a real paper cut. Fix the CLI, fix the skill, run another session. Repeat until tool use becomes boring.

The next level is doing the same with a weaker model. A bit like testing your software on a slow machine: if the experience is acceptable there, it'll be smooth on the expensive hardware. A weaker model exposes ambiguous names, missing examples, noisy output, and hidden assumptions much faster than a frontier model that can reason around them while burning your tokens.

That's the bar I like: not impressive, not magical, just boring. The agent knows what to run, the CLI gives it the shape it expects, and the next step is obvious.

Closing

Most of this is not new. Clean streams, truthful exit codes, predictable command shapes, stable identifiers, structured output, no blocking prompts. Someone writing Unix tools decades ago would recognize every line of it. What's changed is the consumer — and the bill.

Strip the patterns away and four properties are left underneath. The agent knows what to run. The CLI doesn't trip it up. The output is easy to parse. The next step is obvious. Everything above is just means to those four ends.

That's not clever. That's just a good interface.