CLI Patterns for Agentic Workflows
Patterns for building CLI tools that AI assistants and power users can chain, parse, and rely on.
Philosophy
Build CLIs for agentic workflows - AI assistants and power users who chain commands, parse output programmatically, and expect predictable behavior.
Core Principles
| Principle | Meaning | Why It Matters |
|---|---|---|
| Self-documenting | --help is comprehensive and always current | LLMs discover capabilities without external docs |
| Predictable | Same patterns across all commands | Learn once, use everywhere |
| Composable | Unix philosophy - do one thing well | Tools chain together naturally |
| Parseable | --json always available, always valid | Machine consumption without parsing hacks |
| Quiet by default | Data only, no decoration unless requested | Scripts don't break on unexpected output |
| Fail fast | Invalid input = immediate error | No silent failures or partial results |
Design Axioms
- •stdout is sacred - Only data. Never progress, never logging, never decoration.
- •stderr is for humans - Progress bars, colors, tables, warnings live here.
- •Exit codes have meaning - Scripts can branch on failure mode.
- •Help includes examples - The fastest path to understanding.
- •JSON shape is predictable - Same structure across all commands.
Command Architecture
Structural Pattern
<tool> [global-options] <resource> <action> [options] [arguments]
Every CLI follows this hierarchy:
<tool>
├── --version, --help # Global flags
├── auth # Authentication (if required)
│ ├── login
│ ├── status
│ └── logout
└── <resource> # Domain resources (plural nouns)
├── list # Get many
├── get <id> # Get one by ID
├── create # Make new (if supported)
├── update <id> # Modify existing (if supported)
├── delete <id> # Remove (if supported)
└── <custom-action> # Domain-specific verbs
Naming Conventions
| Element | Convention | Valid Examples | Invalid Examples |
|---|---|---|---|
| Tool name | lowercase, 2-12 chars | mytool, datactl | MyTool, my-tool-cli |
| Resource | plural noun, lowercase | invoices, users | Invoice, user |
| Action | verb, lowercase | list, get, sync | listing, getter |
| Long flags | kebab-case | --dry-run, --output-format | --dryRun, --output_format |
| Short flags | single letter | -n, -q, -v | -num, -quiet |
Standard Resource Actions
| Action | HTTP Equiv | Returns | Idempotent |
|---|---|---|---|
list | GET /resources | Array | Yes |
get <id> | GET /resources/:id | Object | Yes |
create | POST /resources | Created object | No |
update <id> | PATCH /resources/:id | Updated object | Yes |
delete <id> | DELETE /resources/:id | Confirmation | Yes |
search | GET /resources?q= | Array | Yes |
Flags & Options
Mandatory Flags
Every command MUST support:
| Flag | Short | Behavior | Output |
|---|---|---|---|
--help | -h | Show help with examples | Help text to stdout, exit 0 |
--json | Machine-readable output | JSON to stdout |
Root command MUST additionally support:
| Flag | Short | Behavior | Output |
|---|---|---|---|
--version | -V | Show version | <tool> <version> to stdout, exit 0 |
Recommended Flags
| Flag | Short | Type | Purpose | Default |
|---|---|---|---|---|
--quiet | -q | bool | Suppress non-essential stderr | false |
--verbose | -v | bool | Increase detail level | false |
--dry-run | bool | Preview without executing | false | |
--limit | -n | int | Max results to return | 20 |
--output | -o | path | Write output to file | stdout |
--format | -f | enum | Output format | varies |
Flag Behavior Rules
- •Boolean flags take no value:
--jsonnot--json=true - •Short flags can combine:
-vqequals-v -q - •Unknown flags are errors: Never silently ignore
- •Repeated flags: Last value wins (or error if inappropriate)
Output Specification
Stream Separation
This is the most critical rule:
| Stream | Content | When |
|---|---|---|
| stdout | Data only | Always |
| stderr | Everything else | Interactive mode |
stdout receives:
- •JSON when
--jsonis set - •Minimal text output when interactive
- •Nothing else. Ever.
stderr receives:
- •Progress indicators (spinners, bars)
- •Status messages ("Fetching...", "Done")
- •Warnings
- •Rich formatted tables
- •Colors and decoration
- •Debug information (
--verbose)
Interactive Detection
import sys
def is_interactive() -> bool:
"""True if connected to a terminal, not piped."""
return sys.stdout.isatty() and sys.stderr.isatty()
| Context | stdout.isatty() | Behavior |
|---|---|---|
| Terminal | True | Rich output to stderr, summary to stdout |
Piped (| jq) | False | Minimal/JSON to stdout |
Redirected (> file) | False | Minimal to stdout |
--json flag | Any | JSON to stdout, suppress stderr noise |
JSON Output Schema
See references/json-schemas.md for complete JSON response patterns.
Key conventions:
- •List responses:
{"data": [...], "meta": {...}} - •Single item:
{"data": {...}} - •Errors:
{"error": {"code": "...", "message": "..."}} - •ISO 8601 dates, decimal money, string IDs
Exit Codes
Semantic exit codes that scripts can rely on:
| Code | Name | Meaning | When |
|---|---|---|---|
| 0 | SUCCESS | Operation completed | Everything worked |
| 1 | ERROR | General/unknown error | Unexpected failures |
| 2 | AUTH_REQUIRED | Not authenticated | No token, token expired |
| 3 | NOT_FOUND | Resource missing | ID doesn't exist |
| 4 | VALIDATION | Invalid input | Bad arguments, failed validation |
| 5 | FORBIDDEN | Permission denied | Authenticated but not authorized |
| 6 | RATE_LIMITED | Too many requests | API throttling |
| 7 | CONFLICT | State conflict | Concurrent modification, duplicate |
Usage
# Script can branch on exit code mytool items get item-001 --json case $? in 0) echo "Success" ;; 2) echo "Need to authenticate" && mytool auth login ;; 3) echo "Item not found" ;; *) echo "Error occurred" ;; esac
Implementation
# Constants EXIT_SUCCESS = 0 EXIT_ERROR = 1 EXIT_AUTH_REQUIRED = 2 EXIT_NOT_FOUND = 3 EXIT_VALIDATION = 4 EXIT_FORBIDDEN = 5 EXIT_RATE_LIMITED = 6 EXIT_CONFLICT = 7 # Usage raise typer.Exit(EXIT_NOT_FOUND)
Error Handling
Error Output Format
With --json, errors output structured JSON to stdout AND a message to stderr:
stderr:
Error: Item not found
stdout:
{
"error": {
"code": "NOT_FOUND",
"message": "Item not found",
"details": {
"item_id": "bad-id"
}
}
}
Error Codes
| Code | Exit | Meaning |
|---|---|---|
AUTH_REQUIRED | 2 | Must authenticate first |
TOKEN_EXPIRED | 2 | Token needs refresh |
FORBIDDEN | 5 | Insufficient permissions |
NOT_FOUND | 3 | Resource doesn't exist |
VALIDATION_ERROR | 4 | Invalid input |
INVALID_ARGUMENT | 4 | Bad argument value |
MISSING_ARGUMENT | 4 | Required argument missing |
RATE_LIMITED | 6 | Too many requests |
CONFLICT | 7 | State conflict |
ALREADY_EXISTS | 7 | Duplicate resource |
INTERNAL_ERROR | 1 | Unexpected error |
API_ERROR | 1 | Upstream API failed |
NETWORK_ERROR | 1 | Connection failed |
Implementation Pattern
def _error(
message: str,
code: str = "ERROR",
exit_code: int = EXIT_ERROR,
details: dict = None,
as_json: bool = False,
):
"""Output error and exit."""
error_obj = {"error": {"code": code, "message": message}}
if details:
error_obj["error"]["details"] = details
if as_json:
print(json.dumps(error_obj, indent=2))
# Always print human message to stderr
console.print(f"[red]Error:[/red] {message}")
raise typer.Exit(exit_code)
Help System
Help Requirements
Every --help output MUST include:
- •Brief description (one line)
- •Usage syntax
- •Options with descriptions
- •Examples (critical for discovery)
Help Format Template
<one-line description> Usage: <tool> <resource> <action> [OPTIONS] [ARGS] Arguments: <arg> Description of positional argument Options: -s, --status TEXT Filter by status -n, --limit INTEGER Max results [default: 20] --json Output as JSON -h, --help Show this help Examples: <tool> <resource> <action> <tool> <resource> <action> --status active <tool> <resource> <action> --json | jq '.[0]'
Examples Are Critical
Examples should show:
- •Basic usage - Simplest invocation
- •Common filters - Most-used options
- •JSON piping - How to chain with
jq - •Real-world scenarios - Actual use cases
Authentication
Auth Commands
Tools requiring authentication MUST implement:
<tool> auth login # Interactive authentication <tool> auth status # Check current state <tool> auth logout # Clear credentials
Credential Storage Priority
Recommended: OS keyring with fallbacks for maximum security
- •
Environment variable (CI/CD, testing)
- •
MYTOOL_API_TOKENor similar - •Highest priority, overrides all other sources
- •
- •
OS Keyring (primary storage - secure)
- •Windows: Credential Manager
- •macOS: Keychain
- •Linux: Secret Service (GNOME Keyring, KWallet)
- •Encrypted at rest, per-user isolation
- •
.env file (development fallback)
- •Plain text in current directory
- •Convenient for local development
- •Must be in
.gitignore
Dependencies:
dependencies = [
"keyring>=24.0.0", # OS keyring access
"python-dotenv>=1.0.0", # .env file support
]
Simple alternative: Just config file in ~/.config/<tool>/
- •Good for tools without sensitive credentials
- •Or when OS keyring adds too much complexity
See references/implementation.md for complete credential storage implementations.
Unauthenticated Behavior
When auth is required but missing:
$ mytool items list Error: Not authenticated. Run: mytool auth login # exit code: 2
$ mytool items list --json
# stderr: Error: Not authenticated. Run: mytool auth login
{"error": {"code": "AUTH_REQUIRED", "message": "Not authenticated. Run: mytool auth login"}}
# exit code: 2
Data Conventions
Date Handling
Input (Flexible): Accept multiple formats for user convenience
| Format | Example | Interpretation |
|---|---|---|
| ISO date | 2025-01-15 | Exact date |
| ISO datetime | 2025-01-15T10:30:00Z | Exact datetime |
| Relative | today, yesterday, tomorrow | Current/previous/next day |
| Relative | last, this (with context) | Previous/current period |
Output (Strict): Always output ISO 8601
{
"created_at": "2025-01-15T10:30:00Z",
"due_date": "2025-02-15",
"month": "2025-01"
}
Money
- •Store as decimal number, not cents
- •Include currency when ambiguous
- •Never format (no "$" or "," in JSON)
{
"total": 1250.50,
"currency": "USD"
}
IDs
- •Always strings (even if numeric)
- •Preserve exact format from source
{
"id": "abc_123",
"legacy_id": "12345"
}
Enums
- •UPPER_SNAKE_CASE in JSON
- •Case-insensitive input
# All equivalent --status DRAFT --status draft --status Draft
{"status": "IN_PROGRESS"}
Filtering & Pagination
Common Filter Patterns
# By status --status DRAFT --status active,pending # Multiple values # By date range --from 2025-01-01 --to 2025-01-31 --month 2025-01 --month last # By related entity --user "Alice" --project "Project X" # Text search --search "keyword" -q "keyword" # Boolean filters --archived --no-archived --include-deleted
Pagination
# Limit results --limit 50 -n 50 # Offset-based --page 2 --offset 20 # Cursor-based --cursor "eyJpZCI6MTIzfQ==" --after "item_123"
Implementation
See references/implementation.md for complete Python implementation templates including:
- •CLI skeleton with Typer
- •Client pattern with httpx
- •Error handling
- •Authentication flows
- •Testing patterns
Anti-Patterns
❌ Output Pollution
# BAD: Progress to stdout
$ bad-tool items list --json
Fetching items...
[{"id": "1"}]
Done!
# GOOD: Only JSON to stdout
$ good-tool items list --json
[{"id": "1"}]
❌ Interactive Prompts
# BAD: Prompts in non-interactive context $ bad-tool items create Enter name: _ # GOOD: Fail fast with required flags $ good-tool items create Error: --name is required
❌ Inconsistent Flags
# BAD: Different flags for same concept $ tool1 list -j $ tool2 list --format=json # GOOD: Same flags everywhere $ tool1 list --json $ tool2 list --json
❌ Silent Failures
# BAD: Success exit code on failure $ bad-tool items delete bad-id Item not found $ echo $? 0 # GOOD: Semantic exit code $ good-tool items delete bad-id Error: Item not found: bad-id $ echo $? 3
Quick Reference
Must-Have Checklist
- •
<tool> --version - •
<tool> --helpwith examples - •
<tool> <resource> list [--json] - •
<tool> <resource> get <id> [--json] - • Semantic exit codes (0, 1, 2, 3, 4, 5, 6, 7)
- • Errors to stderr, data to stdout
- • Valid JSON on
--json - • Stream separation (stdout = data, stderr = UI)
Recommended Additions
- • Authentication commands (
auth login,auth status,auth logout) - • Create/Update/Delete operations
- •
--quietand--verbosemodes - •
--dry-runfor mutations - • Pagination (
--limit,--page) - • Filtering (status, date range, search)
- • Automated tests
Framework Choice
Typer (preferred for new tools):
- •Type hints provide automatic validation
- •Built-in help generation
- •Rich integration for beautiful output
- •Less boilerplate than Click
Click (acceptable for existing tools):
- •Typer is built on Click (100% compatible)
- •Well-structured Click code doesn't need migration
- •Both must follow same output conventions
# Typer (preferred) import typer from rich.console import Console app = typer.Typer() console = Console(stderr=True) # UI to stderr # Click (acceptable) import click from rich.console import Console console = Console(stderr=True) # Same pattern