Disclaimer: This story is fictionalized and based on common patterns and challenges encountered in API integration and CLI tool development. While inspired by real-world scenarios, specific details, clients, and situations have been altered to protect sensitive information and illustrate general principles.
How I turned a complex enterprise API into a simple command-line tool that anyone on my team could use
The Problem
“My team needs to query the API, but they don’t need to know how.”
That was the challenge I faced. We had an enterprise API that required OAuth2 authentication, token management, and understanding of nested JSON responses. My team members—project managers, support staff, and analysts—needed access to this data, but they weren’t developers. They shouldn’t have to:
- Understand OAuth2 flows
- Manage access tokens
- Parse complex JSON structures
- Know which endpoints to call
- Handle authentication errors
- Remember API credentials
They just needed to get the data.
The First Attempt: “Just Use curl”
My initial response was to share the API documentation and a few curl examples. Here’s what that looked like:
1
2
3
4
5
6
7
8
9
10
11
# Step 1: Get access token
TOKEN=$(curl -X POST https://api.example.com/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r '.access_token')
# Step 2: Query the API
curl -X GET "https://api.example.com/v1/accounts" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" | jq
The result? Confusion, frustration, and a lot of questions:
- “What’s a token?”
- “Why do I need to run two commands?”
- “What if the token expires?”
- “Where do I put my credentials?”
- “What’s
jq?”
I realized I was solving the wrong problem. I wasn’t making the API accessible—I was just documenting its complexity.
The Realization: Hide the Complexity
The solution wasn’t better documentation. It was hiding the complexity entirely.
A good CLI tool should:
- Hide what users don’t need to know (authentication, tokens, endpoints)
- Expose what users do need (the data they’re looking for)
- Guide users when they make mistakes (clear error messages)
- Work the way users think (not the way the API works)
So I built a CLI wrapper that transformed this:
1
2
3
# Complex, multi-step process
TOKEN=$(get_token)
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/accounts/ACC-123/service-lines
Into this:
1
2
# Simple, intuitive command
python cli.py terminals --account ACC-123
Building the Tool: Design Principles
1. Command-Based Structure
Instead of exposing API endpoints, I created commands that match what users want to do:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import argparse
def main():
parser = argparse.ArgumentParser(
description="Query API data easily",
formatter_class=argparse.RawDescriptionHelpFormatter
)
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# List accounts
accounts_parser = subparsers.add_parser('accounts', help='List all accounts')
# List terminals
terminals_parser = subparsers.add_parser('terminals', help='List terminals')
terminals_parser.add_argument('--account', required=True, help='Account number')
# Get usage data
usage_parser = subparsers.add_parser('usage', help='Get usage data')
usage_parser.add_argument('--account', required=True)
usage_parser.add_argument('--start-date', help='Start date (YYYY-MM-DD)')
usage_parser.add_argument('--end-date', help='End date (YYYY-MM-DD)')
args = parser.parse_args()
# ... execute command
This structure makes the tool discoverable. Users can run python cli.py --help and see all available commands.
2. Automatic Credential Management
The biggest win was hiding authentication entirely. Users never see tokens, never manage credentials, and never deal with expiration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import os
from dotenv import load_dotenv
class APIClient:
def __init__(self):
load_dotenv() # Load from .env file
self.client_id = os.getenv("API_CLIENT_ID")
self.client_secret = os.getenv("API_CLIENT_SECRET")
self._token = None
self._token_expires_at = 0
def _get_access_token(self):
"""Automatically handle token refresh"""
if self._token and time.time() < self._token_expires_at:
return self._token
# Fetch new token
response = requests.post(
"https://api.example.com/auth/token",
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials"
}
)
response.raise_for_status()
data = response.json()
self._token = data["access_token"]
# Refresh 60 seconds before expiration
self._token_expires_at = time.time() + data["expires_in"] - 60
return self._token
def get(self, endpoint):
"""Make authenticated request"""
token = self._get_access_token()
response = requests.get(
f"https://api.example.com{endpoint}",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
return response.json()
Now users never think about authentication. It just works.
3. User-Friendly Error Messages
API errors are cryptic. A 401 Unauthorized or 403 Forbidden doesn’t help a non-technical user. I transformed these into actionable messages:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def handle_api_error(error):
"""Convert API errors into user-friendly messages"""
if isinstance(error, requests.exceptions.HTTPError):
status_code = error.response.status_code
if status_code == 401:
return "❌ Authentication failed. Please check your credentials in .env file."
elif status_code == 403:
return "❌ Access denied. Your account may not have permission for this resource."
elif status_code == 404:
return "❌ Resource not found. Please check the account or service line ID."
elif status_code == 429:
return "⚠️ Rate limit exceeded. Please wait a moment and try again."
else:
return f"❌ API error ({status_code}): {error.response.text}"
elif isinstance(error, requests.exceptions.ConnectionError):
return "❌ Could not connect to API. Please check your internet connection."
elif isinstance(error, KeyError):
return f"❌ Unexpected API response format. Missing key: {error}"
else:
return f"❌ Unexpected error: {str(error)}"
4. Data Processing and Formatting
Raw API responses are messy. I processed the data before showing it to users:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def get_usage_data(account_number, start_date=None, end_date=None):
"""Get and process usage data"""
# Fetch raw data from API
raw_data = client.post(
f"/accounts/{account_number}/billing-cycles/query",
data={"previousBillingCycles": 6}
)
# Process into user-friendly format
processed = {}
for service_line in raw_data.get("content", {}).get("results", []):
sl_id = service_line["serviceLineNumber"]
processed[sl_id] = {
"total_cap_gb": 0,
"total_consumed_gb": 0,
"daily_usage": []
}
# Aggregate data from multiple billing cycles
for cycle in service_line.get("billingCycles", []):
# Calculate totals
for pool in cycle.get("dataPoolUsage", []):
for block in pool.get("dataBlocks", []):
processed[sl_id]["total_cap_gb"] += block.get("totalAmountGB", 0)
processed[sl_id]["total_consumed_gb"] += block.get("consumedAmountGB", 0)
# Extract daily usage
for daily in cycle.get("dailyDataUsage", []):
date_str = daily["date"].split("T")[0] # Extract date part
# Handle data deduplication (priority vs opt-in)
priority_gb = daily.get("priorityGB", 0)
optin_gb = daily.get("optInPriorityGB", 0)
standard_gb = daily.get("standardGB", 0)
# Use max to avoid double-counting
actual_priority = max(priority_gb, optin_gb)
daily_total = actual_priority + standard_gb
processed[sl_id]["daily_usage"].append({
"date": date_str,
"usage_gb": round(daily_total, 2)
})
# Filter by date range if provided
if start_date and end_date:
processed[sl_id]["daily_usage"] = [
day for day in processed[sl_id]["daily_usage"]
if start_date <= day["date"] <= end_date
]
return processed
Now users get clean, structured data instead of nested JSON.
5. Helpful Output Formatting
I made the output both human-readable and machine-parseable:
1
2
3
4
5
6
7
8
9
10
11
12
import json
def print_results(data, format='json'):
"""Print results in requested format"""
if format == 'json':
print(json.dumps(data, indent=2, default=str))
elif format == 'table':
# Convert to table format for terminal viewing
print_table(data)
elif format == 'csv':
# Export as CSV
print_csv(data)
Users can choose what works best for them:
--format jsonfor scripts and automation--format tablefor quick viewing--format csvfor Excel analysis
The Evolution: Adding Safety Features
As the tool gained users, I added features based on real feedback:
Dry-Run Mode
For operations that could be destructive (like sending emails or updating data), I added a --dry-run flag:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def send_report(client_name, dry_run=False):
"""Send usage report to client"""
report_data = generate_report(client_name)
email_content = format_email(report_data)
if dry_run:
# Save to file instead of sending
preview_file = f"preview_{client_name}.html"
with open(preview_file, 'w') as f:
f.write(email_content)
print(f"✅ Preview saved to {preview_file}")
print(f"📧 Would send to: {get_recipients(client_name)}")
return
# Actually send the email
send_email(email_content, get_recipients(client_name))
print(f"✅ Report sent to {client_name}")
This gives users confidence before executing potentially risky operations.
Validation and Early Error Detection
I validate inputs before making API calls:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def validate_date(date_string):
"""Validate date format"""
try:
datetime.strptime(date_string, '%Y-%m-%d')
return True
except ValueError:
print(f"❌ Invalid date format: {date_string}")
print(" Expected format: YYYY-MM-DD (e.g., 2025-10-13)")
return False
def validate_account(account_number):
"""Check if account exists before processing"""
try:
accounts = client.accounts.list_accounts()
account_numbers = [acc["accountNumber"] for acc in accounts]
if account_number not in account_numbers:
print(f"❌ Account not found: {account_number}")
print(f" Available accounts: {', '.join(account_numbers)}")
return False
return True
except Exception as e:
print(f"❌ Could not validate account: {e}")
return False
Catching errors early prevents wasted API calls and gives users immediate feedback.
Progress Indicators
For long-running operations, I added progress feedback:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def process_multiple_clients(clients):
"""Process multiple clients with progress indication"""
total = len(clients)
for i, client in enumerate(clients, 1):
print(f"\n[{i}/{total}] Processing {client['name']}...")
try:
data = fetch_client_data(client)
print(f" ✅ Fetched {len(data)} records")
except Exception as e:
print(f" ❌ Error: {e}")
continue
print(f"\n✅ Completed {total} clients")
Users know the tool is working, even when it takes time.
The Result: Team Independence
After building this CLI tool, my team could:
- Query the API independently without asking me for help
- Get data on demand without waiting for scheduled reports
- Explore data without understanding API internals
- Feel confident using the tool because of clear error messages
Here’s what changed:
Before:
1
2
3
4
5
6
7
Team Member: "Can you get me the usage data for Client X?"
Me: "Sure, let me check... [5 minutes later] Here's the CSV."
Team Member: "Actually, can you also get it for Client Y?"
Me: [sigh] "Okay, one sec..."
After:
1
2
3
Team Member: [runs command]
Team Member: "Got it, thanks!"
The tool became a force multiplier. One person (me) built it, but the entire team could use it.
Key Lessons Learned
1. Hide Complexity, Not Features
A good CLI tool doesn’t remove functionality—it hides the complexity. Users can still access all the data they need, but they don’t have to understand OAuth2, token management, or API endpoint structures.
2. Error Messages Are User Experience
A cryptic error message breaks the user’s flow. A helpful error message teaches them how to fix the problem. Invest time in making errors actionable.
3. Design for the Least Technical User
If your least technical team member can use the tool, everyone can. Design for them, and you’ll build something that’s intuitive for everyone.
4. Iterate Based on Real Usage
I added features like --dry-run, date validation, and progress indicators based on actual user feedback. The tool evolved to match how people actually used it.
5. Documentation Lives in the Tool
Good CLI tools are self-documenting. The --help command should show everything users need. External documentation is supplementary, not primary.
6. Safety Features Build Confidence
Dry-run modes, validation, and previews make users feel safe. When people feel safe, they use the tool more often and more confidently.
The Pattern: A Reusable Approach
Here’s a template you can use for building your own CLI tools:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#!/usr/bin/env python3
"""
CLI Tool Template
Hides API complexity behind simple commands
"""
import argparse
import os
import sys
from dotenv import load_dotenv
# 1. Load credentials automatically
load_dotenv()
# 2. Initialize API client (handles auth internally)
class APIClient:
def __init__(self):
self.client_id = os.getenv("API_CLIENT_ID")
self.client_secret = os.getenv("API_CLIENT_SECRET")
# ... handle authentication
def get(self, endpoint):
# ... make authenticated request
pass
# 3. Process API data into user-friendly format
def process_api_response(raw_data):
"""Transform complex API response into simple structure"""
# ... processing logic
return processed_data
# 4. User-friendly error handling
def handle_error(error):
"""Convert technical errors into actionable messages"""
# ... error handling
pass
# 5. Command-based CLI structure
def main():
parser = argparse.ArgumentParser(description="Simple API CLI")
subparsers = parser.add_subparsers(dest='command')
# Add commands
list_parser = subparsers.add_parser('list', help='List resources')
list_parser.add_argument('--type', required=True)
get_parser = subparsers.add_parser('get', help='Get resource')
get_parser.add_argument('--id', required=True)
args = parser.parse_args()
# Execute command
try:
client = APIClient()
if args.command == 'list':
data = client.get(f"/{args.type}")
processed = process_api_response(data)
print(json.dumps(processed, indent=2))
elif args.command == 'get':
data = client.get(f"/{args.type}/{args.id}")
processed = process_api_response(data)
print(json.dumps(processed, indent=2))
except Exception as e:
print(handle_error(e))
sys.exit(1)
if __name__ == "__main__":
main()
Conclusion
Building a CLI tool that hides complexity isn’t about dumbing down the API—it’s about elevating the user experience.
When you hide authentication, process data automatically, and provide clear error messages, you’re not removing functionality. You’re making it accessible.
The best tools are the ones that feel simple to use but are powerful underneath. They let users focus on what they want to do, not how the system works.
My team can now query the API independently, explore data on demand, and feel confident using the tool. That’s the real win—not just a working tool, but an empowered team.
Takeaways
- Hide complexity, not features - Users should access everything they need without understanding internals
- Error messages are UX - Make errors actionable, not cryptic
- Design for the least technical user - If they can use it, everyone can
- Iterate based on real usage - Add features based on actual feedback
- Self-documenting tools -
--helpshould show everything - Safety builds confidence - Dry-run modes and validation make users feel safe
The goal isn’t to build a tool that does everything—it’s to build a tool that lets your team do everything they need, simply and confidently.