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: --json not --json=true
-
Short flags can combine: -vq equals -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 --json is 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_TOKEN or 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> --help with 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
-
--quiet and --verbose modes
-
--dry-run for 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