Agent Ergonomics

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Claude Code’s documented MCP output limits, Python 3.12, GraalVM Native Image and the docsctl running example of this sub-section, as checked on 2026-09-30. The token arithmetic is an illustrative estimate, not a benchmark, and the docsctl outputs are illustrative.

A human skims a screenful of output and ignores most of it. An agent reads all of it, every token of it lands in the context window that also holds the task, the plan and the earlier results, and it will pay for that again on every later turn. So a tool that is merely "correct" can still be a bad agent citizen: verbose, unbounded, cryptic when it fails, and slow to start. This page collects the ergonomics that make a CLI cheap to drive. They are the same principles as in Anthropic’s Writing effective tools for agents and in Tool Design, applied to command-line flags and exit codes instead of function schemas. Anthropic’s Building effective agents states the underlying idea: put as much effort into the agent-computer interface as into a human-computer interface, and change a tool’s parameters so that mistakes become hard to make (its example is requiring absolute file paths instead of relative ones). For a CLI, that means small defaults, explicit flags and errors that name the fix, as the sections below describe.

Token budgets

The budget is not abstract. Agent hosts cap what one tool call may return. Claude Code, for one, documents that tool results from MCP servers produce a warning above 10,000 tokens and are limited to 25,000 tokens by default, adjustable with MAX_MCP_OUTPUT_TOKENS (details on Connecting MCP Hosts). That documented cap applies to MCP tool results; a CLI run through a shell tool is governed by that tool’s own output handling, which differs by host and version. The design consequence is the same either way: a wrapper that turns a command into an MCP tool (see Wrapping a CLI as an MCP Server) inherits the command’s output size, so the command should already behave.

Treat every command as having a budget of a few thousand tokens per call, and design toward it:

  • Return the least that lets the agent decide its next step, then let it ask for more (concise first, detail on request).

  • Bound every list with a small default and a cursor.

  • Say when something was cut so the agent does not mistake a truncated answer for a complete one.

  • Make identifiers self-explanatory so the agent does not need a lookup call to interpret them.

Concise vs. detailed modes

Anthropic’s guidance for agent tools is to offer a response-format choice, so the agent can pay for detail only when it needs it. On a CLI the natural expression is the projection from Machine-Readable Output: a compact default and opt-in richness.

Table 1. Concise and detailed modes in docsctl
Mode How What comes back

Concise (default)

docsctl search QUERY --json --fields id,title,score

Identifier, title and score per hit: enough to choose which page to open.

Detailed

docsctl search QUERY --json (all fields) or docsctl show PAGE --json

Snippets and URLs for search; the page content for show, bounded by --max-chars.

Targeted

docsctl show PAGE --section ID --max-chars 2000

One section of one page, cut at a character budget.

A useful shape is a two-step protocol the tool’s help and skill describe (see Self-Describing Help): first search with concise fields to find candidates, then show for the one or two that matter. The agent never pays for the content of the pages it discarded.

Pagination and limits

An unbounded list is the classic way to blow a budget: it is fine on a test repository and catastrophic on a real one. Every command that can return many items gets:

  • A small default --limit (10 in docsctl) and a hard maximum (100), so even --limit 100000 cannot exceed the ceiling; the error for an out-of-range value says what the maximum is.

  • An opaque --cursor TOKEN to fetch the next page, with the token returned as nextCursor (and null when there is nothing more). Cursors are opaque so the server can change how it pages without breaking callers.

  • A stable order, so page two really continues page one (see the deterministic-output rules on the machine-readable page).

  • Counts up front: total in the envelope tells the agent whether a refined query is likely worth it.

Walking the pages of a search
$ docsctl search matrix --json --limit 2 --fields id,title
{"nextCursor":"c_9f2a","query":"matrix","results":[{"id":"irurueta-algebra::matrix-inversion.adoc","title":"Matrix inversion"},{"id":"irurueta-algebra::matrix-decompositions.adoc","title":"Matrix decompositions"}],"schemaVersion":1,"total":7,"truncated":false}
$ docsctl search matrix --json --limit 2 --fields id,title --cursor c_9f2a

The cursor is opaque by contract; the agent passes it back unchanged and stops when nextCursor is null. A refined query (--component irurueta-algebra) is usually cheaper than paging through everything, which is why total is worth having.

References: Anthropic — Writing effective tools for agents (pagination, filtering and truncation), clig.dev.

Truncation markers

Sometimes one item alone is too large: a page of documentation, a log. Cut it at a documented limit and say so in the data, not only in a message on stderr the caller may not read.

A truncated show result
{"component":"irurueta-statistics","content":"= Kalman filter\n\nA Kalman filter estimates ...","id":"irurueta-statistics::kalman.adoc","nextCursor":"c_31b0","schemaVersion":1,"title":"Kalman filter","truncated":true}

Three rules make the marker useful:

  • A boolean truncated field on every result that can be cut, always present (false when complete), so absence never has to be interpreted.

  • A way to get the rest: nextCursor here continues the same page; alternatively --section narrows it. A marker with no continuation is a dead end.

  • Cut at a boundary, such as a paragraph or a JSON record, never in the middle of a UTF-8 character or a structure, so the fragment is still parseable.

The limit itself is a flag (--max-chars), so the agent can tighten it when the context is tight and loosen it when it is about to write something that needs the whole text.

Semantic IDs

Give every entity an identifier a model can read. irurueta-statistics::kalman.adoc tells the agent the component and the file without a lookup; a91f3c07 tells it nothing and invites it to invent one. The same reasoning is behind the "human-readable IDs" advice in Anthropic’s tool-writing guide.

  • Compose IDs from meaningful parts (component::path) and document the grammar.

  • Accept in one command what another emits. The id from search is the PAGE argument of show, unchanged, so an agent never reformats it.

  • Keep them stable across releases when possible; when an ID must change, keep the old one working for a while.

  • Namespace commands and IDs consistently (docsctl list-components, docsctl show), for the same reason tool names are namespaced: it reduces the wrong-command mistakes discussed in CLI Design Fundamentals.

Errors that carry remediation

An error message is the one moment the tool knows exactly what went wrong and how to fix it. For a person that is nice; for an agent it is the difference between recovering in one turn and thrashing. The docsctl error envelope (on stderr in machine modes, see Streams & Exit Codes) has a hint field for exactly this:

An error that says what to do next
{"error":{"code":"AUTH_REQUIRED","details":{},"exitCode":7,"hint":"Run `docsctl login` or set DOCSCTL_TOKEN.","message":"No credentials found.","retryable":false},"schemaVersion":1}
  • Name the command or flag to run, literally, in a form the agent can execute ("run `docsctl login`").

  • Show a correct example when the mistake was in the syntax: --component is required; try: docsctl publish --component irurueta-algebra --dry-run.

  • List the valid values for an enum or an unknown field; do not make the agent guess.

  • Say whether retrying can help (retryable) and, for rate limits, after how long. Exit code 6 means "transient, try again"; 7 means "fix credentials"; 5 means "ask before continuing".

  • Never put secrets or stack traces in the hint; keep diagnostics behind --verbose on stderr.

The agent’s loop then becomes: read the exit code, parse the envelope, follow the hint. There is nothing to interpret.

Timeouts

An agent that waits forever on a hung network call wastes wall-clock time and, in hosted setups, its own turn limit. Every command that touches the network takes --timeout SECONDS (default 30 in docsctl, also DOCSCTL_TIMEOUT and the config key timeout).

  • Set a finite default, short enough that a stuck call fails while the agent’s context is still fresh.

  • Fail with the retryable exit code (6) and a TIMEOUT error, so the agent knows to retry or back off, not to change its approach.

  • Distinguish connect from total time internally; expose one flag unless callers demonstrably need two.

  • Clean up on SIGINT and SIGTERM. When the host kills a slow command, docsctl exits 130 or 143 after releasing locks and temporary files, so the next call does not find a half-finished state.

A bounded HTTP call with a retryable failure (Python, standard library)
import json
import sys
import urllib.error
import urllib.request

EXIT_RETRYABLE = 6


def fetch(url: str, timeout: float) -> bytes:
    try:
        with urllib.request.urlopen(url, timeout=timeout) as response:
            return response.read()
    except (TimeoutError, urllib.error.URLError) as exc:
        envelope = {
            "schemaVersion": 1,
            "error": {
                "code": "TIMEOUT",
                "exitCode": EXIT_RETRYABLE,
                "message": f"No answer within {timeout:g} seconds.",
                "hint": "Retry, or raise --timeout.",
                "retryable": True,
                "details": {"reason": str(exc)},
            },
        }
        print(json.dumps(envelope, sort_keys=True), file=sys.stderr)
        sys.exit(EXIT_RETRYABLE)

Fast startup

An agent may run a command dozens of times in one session, and a shell tool call is synchronous: the model waits. A tool that takes a second or two to start (a JVM warming up, a Node process loading a large dependency tree, a Python interpreter importing heavy libraries at module level) turns every call into a pause and makes an agent’s loop noticeably slower than the same loop with instant tools.

  • Do nothing at import time. Load optional dependencies lazily, inside the command that needs them; keep --help and --version free of network and file-system probing.

  • Avoid daemons and warm-up you cannot skip; if a background process is essential, expose a status command and make the cold path work as well.

  • Ship a native or small binary where the platform allows it. On the JVM, GraalVM Native Image compiles a Java application ahead of time into a native executable that starts without a JVM warm-up. That matters far more for a command that lives for 200 milliseconds than for a server that lives for days. The practical recipe with picocli and its code generator is covered on Java CLI Frameworks; Go and Rust produce native binaries by default.

  • Measure it. A tool such as hyperfine runs a command repeatedly and reports mean and spread, for example hyperfine 'docsctl --version'. Measure your own tool on your own machine instead of trusting anyone’s figures. For scale, Java CLI Frameworks records one measurement: about 111 ms for java -jar docsctl.jar --version of the small picocli docsctl on an Apple M2 with JDK 25.

Reference: GraalVM Native Image.

A before/after token estimate

To see how much projection and limits matter, estimate the cost of one docsctl search call. A rough rule of thumb for English and JSON text is about four characters per token (the real ratio depends on the model’s tokenizer and the content, so measure with your model’s token counter before relying on it):

where is the number of characters returned, the number of results and the average characters per result.

Take the default . A full record (id, component, title, URL, score and a snippet) is about characters in this example; the projection --fields id,title,score leaves about . These per-record sizes are assumptions made for the illustration.

Table 2. Illustrative estimate for one search call (assumed record sizes, not measured)
Call Results Est. characters Est. tokens

docsctl search kalman --json

10

6200

1550

docsctl search kalman --json --fields id,title,score

10

950

238

docsctl search kalman --json --fields id,title,score --limit 3

3

285

72

The saving repeats: those results stay in the context for every later turn, so a 1300-token difference in one call is paid many times over a long session. To replace the estimate with a measurement, capture real output and count it with the token counter of the model you use:

Counting real output (Python; the character heuristic is a placeholder for your model’s tokenizer)
import subprocess


def approx_tokens(argv: list[str]) -> int:
    out = subprocess.run(argv, capture_output=True, text=True, check=True).stdout
    return -(-len(out) // 4)  # ceiling division of characters by four


print(approx_tokens(["docsctl", "search", "--json", "--", "kalman"]))
print(approx_tokens(["docsctl", "search", "--json", "--fields", "id,title,score", "--", "kalman"]))

No vendor’s comparison figures are used here: the arithmetic uses only assumed sizes of a fictional tool. For how CLI and MCP compare in this respect see CLI vs. MCP.