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.
Reference: Claude Code — MCP (output limits).
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.
| Mode | How | What comes back |
|---|---|---|
Concise (default) |
|
Identifier, title and score per hit: enough to choose which page to open. |
Detailed |
|
Snippets and URLs for search; the page content for |
Targeted |
|
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.
Reference: Anthropic — Writing effective tools for agents.
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 indocsctl) and a hard maximum (100), so even--limit 100000cannot exceed the ceiling; the error for an out-of-range value says what the maximum is. -
An opaque
--cursor TOKENto fetch the next page, with the token returned asnextCursor(andnullwhen 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:
totalin the envelope tells the agent whether a refined query is likely worth it.
$ 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.
{"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
truncatedfield on every result that can be cut, always present (falsewhen complete), so absence never has to be interpreted. -
A way to get the rest:
nextCursorhere continues the same page; alternatively--sectionnarrows 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
idfromsearchis thePAGEargument ofshow, 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:
{"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:
--componentis 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
--verboseon 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
TIMEOUTerror, 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,
docsctlexits 130 or 143 after releasing locks and temporary files, so the next call does not find a half-finished state.
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)
Reference: Python — urllib.request.
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
--helpand--versionfree of network and file-system probing. -
Avoid daemons and warm-up you cannot skip; if a background process is essential, expose a
statuscommand 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 forjava -jar docsctl.jar --versionof the small picoclidocsctlon 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.
| Call | Results | Est. characters | Est. tokens |
|---|---|---|---|
|
10 |
6200 |
1550 |
|
10 |
950 |
238 |
|
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:
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.