by Martin Monperrus

TLDR: The fastest-growing user of your command-line tool is an agent, not a human. Agent-native means: headless by default, JSON on stdout, actionable errors on stderr, honest exit codes. On the format side, there are three layers that want opposite things — markdown for instructions, JSON for the wire, free-form for generation. Most of this is just the Unix philosophy, finally enforced.

Pattern: CLI as the Native Agent Surface

A CLI is text in, text out, composable, with exit codes and --help. That contract was designed in 1978 for humans at a terminal. It turns out to be the contract agents need too.

Scalekit ran 75 comparative tasks against GitHub: gh CLI versus the Copilot MCP server, with the same prompts and model. The CLI used 200–9,000 tokens per task; MCP used 32,000–82,000. At 10k monthly operations, that was $3.20 versus $55.20. Reliability was 100% for the CLI and 72% for MCP, mainly due to timeouts (benchmark write-up).

There is a deeper reason beyond tokens and reliability: models have read the history of the terminal. git, curl, jq, and ffmpeg are in the weights. A bespoke tool schema is not, and its context cost recurs on every turn.

The ecosystem converges on a filesystem, executables, and stdout. That is a shell.

Pattern: Failure-Driven Interface Design

This pattern grounds interface design in the self-teaching error surface: failure modes observed across hundreds of real agent trajectories (Claude, Copilot, Codex, agent_probe) captured in a local trajectory corpus and searched with recoll-traj. The failures are boringly repetitive: TUI traps, silent successful exits, retry loops caused by unhelpful errors, ANSI-decorated output, and invisible stdin prompts.

An audit of eight local scripts against this checklist found that seven had no --json, six mishandled error prefixes or streams, and five had no real --help. The rules are easy and nobody follows them, including their authors.


Pattern: Headless by Default

Failure mode: An agent passes --prompt "do X" expecting headless execution. The CLI interprets this as seeding a TUI, opens an interactive session, and the agent process hangs until the timeout fires.

Observed example: opencode --prompt "..." seeds the TUI; the correct headless invocation is opencode run [message]. A benchmark harness had to detect which invocation mode each CLI supported by parsing --help output — a fragile heuristic that broke for deepseek-v4-flash because the flag detection returned wrong results, causing all tasks to time out.

Rules: - Provide a flag (e.g., --non-interactive, --headless, -p, or a subcommand like run) that is unambiguous and fully bypasses any TUI or interactive flow. - Document this flag prominently in --help, with a one-line example. - When stdin is not a TTY and no flag was given, default to non-interactive mode and do not block. - Keep the non-interactive entry point stable across versions; changing it forces all calling harnesses to update their detection logic.

Pattern: Honest Exit Status

Failure mode: A command succeeds silently (empty stdout, exit 0) or fails silently (error swallowed, still exit 0). The agent cannot distinguish between “completed normally,” “did nothing,” and “crashed quietly.”

Observed example: Running python /tmp/fib_demo.py returned empty stdout with exit code 0 — the agent couldn’t tell whether it succeeded or not. The workaround documented in improved system prompts: append ; echo "EXIT: $?" to every command to extract the exit code explicitly.

Rules: - Exit 0 means success. Exit non-zero means failure. Never exit 0 after printing an error message. - Provide named or documented exit codes for distinct failure categories (e.g., 1 = user error, 2 = configuration error, 3 = network failure). Even a simple two-value convention (0 / non-zero) beats an undocumented mix. - On success, print at least one line of confirmation. A silent success is indistinguishable from a silent failure. - Propagate exit codes through wrappers; do not swallow a subprocess’s non-zero exit with an unconditional exit 0.

Pattern: Actionable, Singular Errors

Failure mode: An agent reads an error like Permission denied or No such file or directory and enters a guess-and-retry loop, often repeating the same failing invocation multiple times.

Observed examples: - 139 matches for permission denied in the corpus, many showing an agent retrying the identical failing SSH or config-file access 5–10 times. - 75 matches for no such file or directory, including agents assuming standard filenames (pyproject.toml, setup.py, package.json) exist without checking. - unrecognized arguments appeared 31 times, often after an agent hallucinated a flag (--yes, --lines 40, --assign-identity) not present in the actual CLI.

Rules: - Print the full path that was not found, not just the filename. - For permission errors, state what permission is required and, if recoverable, how to fix it (e.g., “Run as root” or “Check that your SSH key is loaded with ssh-add”). - For unrecognized flags, suggest the closest valid flag or print the relevant section of help. - Do not combine errors. If three things are wrong, emit three separate, numbered error lines. - Prefix error output with ERROR: or error: consistently so agents can detect it by string matching. Avoid printing errors to stdout; use stderr.

Pattern: Machine-Readable Results

Failure mode: The agent’s downstream code tries to parse human-readable tabular or prose output. Column alignment, header lines, pluralization, and unit suffixes all vary by terminal width or locale, making regex-based parsing fragile.

Observed example: ANSI color codes embedded in stdout (\x1b[32m, etc.) broke agents trying to extract fields by splitting on whitespace. A --no-color flag was added as a workaround, but the agent still had to strip header lines and unit strings.

Rules: - Emit machine-readable JSON. Either make JSON the default output (recommened) or provide a --json / --output json flag. - If JSON is the default, say so explicitly in --help and show an example of the output schema. - If a human-readable default is kept, suppress all decorative output (spinners, progress bars, color, banners) when --json is active. - Keep the JSON schema backwards-compatible across minor versions; add fields, never remove or rename them. - At minimum, provide --no-color / NO_COLOR env-var support (the standard from no-color.org).

Pattern: Explicit Input Channels

Failure mode: A CLI reads from stdin without being told to. When invoked by an agent as a subprocess with stdin connected to /dev/null or a pipe, it blocks indefinitely waiting for input that never arrives.

Observed examples: GPG’s interactive passphrase prompt; ssh waiting for a host-key confirmation; package managers asking “Continue? [y/N]” — all caused agent hangs that only resolved on timeout.

Rules: - Only read from stdin when the user explicitly passes - as an argument or invokes an explicitly interactive subcommand. - If stdin is not a TTY and the CLI would otherwise prompt, fail immediately with a clear error: error: interactive input required but stdin is not a terminal; use --yes or --passphrase=FILE. - Provide --yes / --assume-yes / --force flags for operations that would otherwise prompt for confirmation. - For credentials, accept --password-file, --token-file, or environment variables so agents can inject secrets without opening a pipe to stdin.

Pattern: Explicit Project Roots

Failure mode: An agent is launched from a directory where the CLI’s assumptions don’t hold (e.g., started from $HOME instead of the project root). The CLI silently finds no config file and proceeds with defaults, producing subtly wrong results.

Observed example: A benchmark runner added a guard that exits with an error when the working directory is $HOME, because agents launched from the wrong directory would silently use global defaults. File lookups for pyproject.toml, setup.py, and package.json failed with No such file or directory in 75 recorded steps, all caused by wrong working directory.

Rules: - State the expected working directory in --help (e.g., “Must be run from the repository root”). - Detect and fail explicitly when a required file or directory does not exist: error: pyproject.toml not found in current directory; run from the project root. - Never silently fall back to a home-directory config when a project-level config was expected. - Provide a --project-dir or --root flag so callers can set the working context without cd.

Pattern: Stable, Discoverable Interface

Failure mode: An agent probes --help to detect which flags are available. If the help text is verbose prose, uses inconsistent formatting, or omits flags, the agent may hallucinate flags based on training data, leading to unrecognized arguments errors.

Observed examples: - Agents guessed -n, --lines 40, --base-url, --key-name, --assign-identity — none of which existed in the target CLI. - A benchmark harness detected the non-interactive flag by scanning --help output for --non-interactive or --prompt; this broke for CLIs where the flag was documented differently.

Rules: - List every flag on its own line, in the form --flag-name VALUE Description. - Include at least one usage example per subcommand. - Document the non-interactive/headless flag explicitly with an example invocation. - Keep --help output stable: same flags, same order, same phrasing across patch versions. Agents may cache the output between runs. - Emit --help to stdout (not stderr) so harnesses can capture it with standard redirection.

Pattern: Bounded Execution

Failure mode: A CLI operation hangs indefinitely (network call, subprocess, lock contention). The outer agent times out the whole task, losing all context about the individual command’s progress.

Observed example: 97.3–97.5% of API calls in one session timed out due to backend degradation. The agent’s only signal was wall-clock timeout on the outer process; it had no way to know whether individual calls had partially succeeded.

Rules: - Set sensible internal timeouts for all I/O operations (network, subprocesses, file locks). - Expose a --timeout SECONDS flag so callers can tune per-call limits. - On timeout, emit error: operation timed out after 30s (use --timeout to adjust) and exit non-zero. - For long-running commands, print progress periodically (e.g., [3/10] processing...) so agents can distinguish “slow but working” from “silently hung.” - Never let a background thread or daemon prevent clean process exit; respect SIGTERM promptly.

Pattern: Native Failure Signaling

Failure mode (API-level): A CLI that wraps an LLM API returns an error string as a successful tool result. The model sees "ERROR: invalid argument" as the tool’s output and — not knowing the call failed — keeps retrying with the same bad arguments, producing an infinite loop.

Observed example: claude-haiku-query.py built tool_result blocks without "is_error": True when the tool returned an error. The Anthropic API treats such blocks as successful results; the model never learned to fix its call, causing an infinite retry loop. The fix detects the ERROR: prefix and sets "is_error": True.

Rules: - When your CLI is used as a tool in an agentic framework, signal failure through the framework’s error channel, not just through text content. - With the Anthropic Messages API, set "is_error": true on tool_result blocks that contain error content. - For OpenAI-compatible APIs, return an error object in the tool response. Do not stuff error text into content. - Use consistent error prefixes (ERROR:, error:) so harnesses can detect failures even when they cannot inspect the exit code.

Pattern: Safe Mutation Preview

Failure mode: An agent runs a command that changes files, cloud state, credentials, or databases before it has enough context to know whether the operation is safe. If the command has no preview mode, the agent can only choose between doing nothing and making an irreversible change.

Rules: - Provide --dry-run for every command that mutates external state or writes outside the current working tree. - Make dry-run output specific: list the files, resources, records, or services that would change. - Use the same validation path for dry runs and real runs so dry runs catch missing credentials, invalid flags, and permission errors. - In JSON mode, include an explicit "would_change": true|false field and a list of planned operations. - Never perform hidden writes, migrations, network calls with side effects, or credential updates during a dry run.

Pattern: Retry-Safe Mutations

Failure mode: A command partially succeeds, then times out or crashes. The agent retries the same command and creates duplicate resources, repeats an expensive operation, or corrupts state because the CLI cannot distinguish “already done” from “needs doing.”

Rules: - Design create/update/delete operations to be safe to retry when possible. - Provide idempotency keys for remote operations: --idempotency-key KEY. - If an operation has multiple phases, persist enough state to resume or roll back cleanly. - On partial failure, report exactly which steps completed and which did not. - Treat “already exists in the requested state” as success unless the user explicitly asks for strict creation semantics.

Pattern: Clean Result Channel

Failure mode: A CLI prints progress bars, warnings, debug logs, update notices, or telemetry messages into stdout. The agent’s parser receives a mix of data and commentary, then fails or extracts the wrong value.

Rules: - Reserve stdout for the command’s primary result. - Send diagnostics, warnings, progress, and logs to stderr. - When JSON output is active, stdout must contain only valid JSON or newline-delimited JSON. - Provide --quiet to suppress non-essential diagnostics and --verbose / --debug to opt into more detail. - Disable update checks, telemetry notices, and “new version available” banners in non-interactive mode unless explicitly requested.

Pattern: Explainable Configuration

Failure mode: An agent changes an environment variable or config file but the CLI silently reads a different source. The agent then keeps editing the wrong file or passes duplicate flags trying to override hidden defaults.

Rules: - Document configuration precedence: flags, environment variables, project config, user config, built-in defaults. - Provide a command such as config explain or --show-config that prints each effective setting and where it came from. - Include config file paths in errors and diagnostics. - In non-interactive mode, do not create or modify user-level config unless the command explicitly says it will. - Avoid implicit global state for project-scoped commands; prefer explicit --config, --project-dir, or environment variables.

Pattern: Visible Concurrency Control

Failure mode: Multiple agent commands run in parallel against the same files or remote resources. One command waits on a hidden lock, overwrites another command’s output, or fails with a vague conflict error.

Rules: - Use lock files or remote leases for operations that cannot safely run concurrently. - When waiting on a lock, print who holds it, what resource is locked, and how long the command will wait. - Expose --lock-timeout SECONDS for callers that need bounded execution. - Use atomic writes for generated files. Write to a temporary file, then rename. - For conflict errors, include the conflicting resource identifier and the safest recovery command.


Pattern: Defensive Agent Boundary

Other projects converge on the same checklist. agentnative-cli is a linter over eight principles: non-interactive by default, structured output, progressive help, actionable errors, safe retries, composable structure, bounded responses, and a discoverable skill bundle. The Agent-Friendly CLI Spec extends this to 98 verifiable rules across three certification levels. CLI-Anything auto-generates agent-native CLIs for GUI applications such as GIMP, Blender, QGIS, and LibreOffice.

The principle people skip is that the agent is untrusted. Treat it as a public API client with a plausible-sounding hallucination generator attached. Validate its input accordingly, fail closed, and make safe recovery explicit.