How to Build a CLI Agent with Claude Code
Practical tutorial: It provides technical guidance on a specific programming concept, which is useful for developers but not groundbreaking.
How to Build a CLI Agent with Claude Code
Table of Contents
- How to Build a CLI Agent with Claude Code
- Create a virtual environment
- Install required packages
- config.py
- agent.py
- cli.py
📺 Watch: Neural Networks Explained
Video by 3Blue1Brown
If you've spent any time wrestling with shell commands, debugging CI pipelines, or trying to remember the exact flags for find, you've probably wished for a smarter terminal. Claude Code, released As of June 2026, it's one of the few LLM-based tools that can read your files, execute commands, and edit code with your permission.
This tutorial walks through building a production-grade CLI agent that uses Claude Code as its reasoning engine. You'll learn how to structure prompts for terminal operations, handle permission boundaries, and build a tool that actually ships code instead of just suggesting it.
Understanding the Claude Code Architecture
Claude Code isn't just a chatbot wrapper. According to Anthropic [6]'s documentation, it operates as a "terminal-native agent" that can read and write files, execute shell commands, and manage git operations. The key architectural insight is that Claude Code uses a permission model where every potentially destructive action requires explicit user approval.
The core architecture breaks down into three layers:
- The Claude LLM - The reasoning engine that understands natural language and generates actions
- The Tool Layer - A set of functions Claude can call:
read_file,write_file,execute_command,search_files, etc. - The Permission System - A security layer that intercepts dangerous operations
For our CLI agent, we'll build a Python wrapper that communicates with Claude Code through its API, adding custom tools for project-specific operations.
Prerequisites and Environment Setup
Before writing any code, ensure you have:
- Python 3.11+ installed
- An Anthropic API key with Claude Code access
pipfor package management- Git configured on your machine
# Create a virtual environment
python3 -m venv claude-agent-env
source claude-agent-env/bin/activate
# Install required packages
pip install anthropic==0.49.0
pip install pydantic==2.9.0
pip install typer==0.12.0
pip install rich==13.9.0
pip install pyyaml==6.0.2
The anthropic package provides the official Python SDK. typer handles CLI argument parsing, rich gives us pretty terminal output, and pyyaml lets us load configuration files.
Building the Core Agent Framework
Let's start with the agent's configuration system. This defines how Claude Code interacts with your project.
# config.py
from pydantic import BaseModel, Field
from typing import Optional, List
import yaml
from pathlib import Path
class AgentConfig(BaseModel):
"""Configuration for the Claude Code CLI agent."""
project_root: Path = Field(
default=Path.cwd(),
description="Root directory of the project Claude will work on"
)
allowed_commands: List[str] = Field(
default=["git", "npm", "pip", "python", "make", "docker"],
description="Shell commands Claude is allowed to execute"
)
max_tokens_per_step: int = Field(
default=4096,
ge=512,
le=8192,
description="Maximum tokens Claude can use per reasoning step"
)
temperature: float = Field(
default=0.2,
ge=0.0,
le=1.0,
description="Lower temperature for more deterministic code generation"
)
permission_timeout: int = Field(
default=60,
description="Seconds before pending permission requests expire"
)
@classmethod
def from_yaml(cls, path: Path) -> "AgentConfig":
"""Load configuration from a YAML file."""
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
with open(path, "r") as f:
data = yaml.safe_load(f)
return cls(**data)
def validate_command(self, command: str) -> bool:
"""Check if a command is in the allowed list."""
base_command = command.strip().split()[0]
return base_command in self.allowed_commands
The validate_command method is critical for security. Without it, Claude could theoretically execute rm -rf / or other destructive operations. We explicitly whitelist only safe commands.
Now let's build the actual agent that communicates with Claude Code:
# agent.py
import os
import json
import asyncio
from typing import AsyncGenerator, Optional
from datetime import datetime
from pathlib import Path
from anthropic import AsyncAnthropic
from rich.console import Console
from rich.markdown import Markdown
from rich.prompt import Confirm
from config import AgentConfig
console = Console()
class ClaudeCodeAgent:
"""A CLI agent powered by Claude Code."""
def __init__(self, config: AgentConfig):
self.config = config
self.client = AsyncAnthropic(
api_key=os.environ.get("ANTHROPIC_API_KEY")
)
self.conversation_history: list[dict] = []
self.pending_actions: list[dict] = []
if not self.client.api_key:
raise ValueError(
"ANTHROPIC_API_KEY environment variable not set. "
"Get your key from https://console.anthropic.com"
)
async def execute_task(self, task: str) -> AsyncGenerator[str, None]:
"""
Execute a task using Claude Code with permission checks.
This generator yields status updates as the agent works.
"""
system_prompt = self._build_system_prompt()
# Add the user's task to conversation history
self.conversation_history.append({
"role": "user",
"content": task
})
# Main reasoning loop
while True:
response = await self.client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=self.config.max_tokens_per_step,
temperature=self.config.temperature,
system=system_prompt,
messages=self.conversation_history
)
# Extract the response content
content = response.content[0].text if response.content else ""
# Check for tool calls in the response
tool_calls = self._extract_tool_calls(content)
if not tool_calls:
# No more tool calls - this is the final response
yield content
self.conversation_history.append({
"role": "assistant",
"content": content
})
break
# Process each tool call
for tool_call in tool_calls:
if not self._request_permission(tool_call):
yield f"⛔ Permission denied: {tool_call['action']}"
continue
result = await self._execute_tool(tool_call)
yield f"🔧 {tool_call['action']}: {result['status']}"
# Add the tool result to conversation history
self.conversation_history.append({
"role": "user",
"content": json.dumps({
"tool_result": result,
"tool_call": tool_call
})
})
def _build_system_prompt(self) -> str:
"""Build the system prompt that defines Claude's behavior."""
return f"""You are a CLI agent operating in the project at {self.config.project_root}.
RULES:
1. You can execute commands from: {', '.join(self.config.allowed_commands)}
2. Always explain what you're about to do before doing it.
3. When writing code, prefer existing project patterns.
4. Use git commits with descriptive messages.
5. If you're unsure about something, ask the user.
To execute a command, use the format:
<tool_call>
{{"action": "execute_command", "command": "ls -la"}}
</tool_call>
To read a file:
<tool_call>
{{"action": "read_file", "path": "src/main.py"}}
</tool_call>
To write a file:
<tool_call>
{{"action": "write_file", "path": "src/new_file.py", "content": "print('hello')"}}
</tool_call>
Current project structure (top-level files):
{self._get_project_structure()}
"""
def _get_project_structure(self) -> str:
"""Get a summary of the project structure."""
try:
import subprocess
result = subprocess.run(
["find", str(self.config.project_root), "-maxdepth", "2", "-type", "f"],
capture_output=True, text=True, timeout=5
)
files = result.stdout.strip().split("\n")[:30] # Limit to 30 files
return "\n".join(files)
except Exception:
return "Could not read project structure"
def _extract_tool_calls(self, content: str) -> list[dict]:
"""Parse tool calls from Claude's response."""
import re
pattern = r'<tool_call>\s*({.*?})\s*</tool_call>'
matches = re.findall(pattern, content, re.DOTALL)
tool_calls = []
for match in matches:
try:
tool_calls.append(json.loads(match))
except json.JSONDecodeError:
continue
return tool_calls
def _request_permission(self, tool_call: dict) -> bool:
"""Ask the user for permission before executing actions."""
action = tool_call.get("action", "unknown")
# Read operations don't need permission
if action in ["read_file", "search_files"]:
return True
# Write and execute operations need explicit permission
console.print(f"\n[bold yellow]⚠️ Permission Required[/bold yellow]")
console.print(f"Action: {action}")
if action == "execute_command":
command = tool_call.get("command", "")
console.print(f"Command: [bold]{command}[/bold]")
if not self.config.validate_command(command):
console.print("[red]Command not in allowed list[/red]")
return False
elif action == "write_file":
path = tool_call.get("path", "")
console.print(f"File: [bold]{path}[/bold]")
return Confirm.ask("Allow this action?")
async def _execute_tool(self, tool_call: dict) -> dict:
"""Execute a tool call and return the result."""
action = tool_call.get("action")
if action == "execute_command":
return await self._execute_command(tool_call["command"])
elif action == "read_file":
return self._read_file(tool_call["path"])
elif action == "write_file":
return self._write_file(tool_call["path"], tool_call["content"])
else:
return {"status": "error", "message": f"Unknown action: {action}"}
async def _execute_command(self, command: str) -> dict:
"""Execute a shell command safely."""
import subprocess
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30,
cwd=self.config.project_root
)
return {
"status": "success" if result.returncode == 0 else "error",
"stdout": result.stdout[:2000], # Limit output size
"stderr": result.stderr[:1000],
"return_code": result.returncode
}
except subprocess.TimeoutExpired:
return {"status": "error", "message": "Command timed out after 30 seconds"}
except Exception as e:
return {"status": "error", "message": str(e)}
def _read_file(self, path: str) -> dict:
"""Read a file from the project."""
full_path = self.config.project_root / path
if not full_path.exists():
return {"status": "error", "message": f"File not found: {path}"}
if not full_path.is_relative_to(self.config.project_root):
return {"status": "error", "message": "Path traversal detected"}
try:
content = full_path.read_text(encoding="utf-8")
return {
"status": "success",
"content": content[:5000], # Limit file size
"size": len(content)
}
except Exception as e:
return {"status": "error", "message": str(e)}
def _write_file(self, path: str, content: str) -> dict:
"""Write content to a file."""
full_path = self.config.project_root / path
if not full_path.is_relative_to(self.config.project_root):
return {"status": "error", "message": "Path traversal detected"}
try:
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content, encoding="utf-8")
return {
"status": "success",
"path": str(full_path),
"size": len(content)
}
except Exception as e:
return {"status": "error", "message": str(e)}
This agent implements several production-grade features:
- Permission system: Every write or execute action requires user confirmation
- Path traversal protection: Files outside the project root are rejected
- Output size limits: Prevents Claude from being overwhelmed by large outputs
- Command timeout: Prevents runaway processes
- Conversation history: Maintains context across multiple reasoning steps
Building the CLI Interface
Now let's create the actual command-line interface using typer:
# cli.py
import asyncio
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.markdown import Markdown
from rich.progress import Progress, SpinnerColumn, TextColumn
from config import AgentConfig
from agent import ClaudeCodeAgent
app = typer.Typer(
name="claude-agent",
help="A CLI agent powered by Claude Code",
add_completion=False
)
console = Console()
@app.command()
def run(
task: str = typer.Argument(
..,
help="The task to execute (e.g., 'add error handling to main.py')"
),
config: Optional[Path] = typer.Option(
None,
"--config", "-c",
help="Path to YAML configuration file",
exists=True,
file_okay=True,
dir_okay=False
),
verbose: bool = typer.Option(
False,
"--verbose", "-v",
help="Show detailed output including tool calls"
)
):
"""Execute a task using Claude Code."""
# Load configuration
if config:
agent_config = AgentConfig.from_yaml(config)
else:
agent_config = AgentConfig()
# Initialize the agent
try:
agent = ClaudeCodeAgent(agent_config)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
console.print(f"[bold]Claude Code Agent[/bold]")
console.print(f"Project: {agent_config.project_root}")
console.print(f"Task: {task}\n")
# Execute the task
async def run_task():
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
transient=True,
) as progress:
progress.add_task(description="Claude is working..", total=None)
async for update in agent.execute_task(task):
if verbose:
console.print(update)
# Print the final response
console.print("\n[bold green]Result:[/bold green]")
console.print(Markdown(agent.conversation_history[-1]["content"]))
asyncio.run(run_task())
@app.command()
def init():
"""Create a default configuration file."""
config_path = Path.cwd() / "claude-agent.yml"
if config_path.exists():
console.print(f"[yellow]Config file already exists: {config_path}[/yellow]")
if not typer.confirm("Overwrite?"):
raise typer.Exit()
default_config = {
"project_root": str(Path.cwd()),
"allowed_commands": ["git", "npm", "pip", "python", "make", "docker"],
"max_tokens_per_step": 4096,
"temperature": 0.2,
"permission_timeout": 60
}
import yaml
with open(config_path, "w") as f:
yaml.dump(default_config, f, default_flow_style=False)
console.print(f"[green]Created config file: {config_path}[/green]")
console.print("Edit this file to customize Claude's behavior.")
if __name__ == "__main__":
app()
The CLI interface provides two commands:
run- Execute a task with Claude Codeinit- Generate a default configuration file
Pitfalls and Production Tips
After building and testing this agent across several projects, here are the real issues you'll encounter:
1. Token Limits Will Bite You
Claude Code's context window fills up fast. Each file read, command output, and conversation turn consumes tokens. After about 10-15 tool calls, you'll hit the context limit. Solution: implement a summarization step that compresses conversation history.
async def _summarize_history(self) -> None:
"""Compress conversation history when it gets too long."""
# Rough token estimation: 4 chars per token
total_chars = sum(
len(msg.get("content", ""))
for msg in self.conversation_history
)
if total_chars > 8000: # ~2000 tokens
summary_prompt = "Summarize the work done so far in 2-3 sentences."
# .. call Claude to summarize
self.conversation_history = [{"role": "system", "content": summary}]
2. Command Output Can Break Claude
Running git log on a large repository can return 10,000+ lines. Claude will either truncate or choke. Always limit output:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=30
)
# Never pass more than 2000 lines to Claude
stdout_lines = result.stdout.split("\n")[:2000]
3. The Permission Model Is Too Slow
In practice, asking for permission on every file write makes the agent unusable. Better approach: batch permissions. Let Claude propose a plan, then ask for permission once.
# Instead of per-action permissions:
# "Write file A? [y/N]"
# "Write file B? [y/N]"
# "Execute command? [y/N]"
# Use plan-based permissions:
# "Claude proposes to:
# 1. Read src/main.py
# 2. Write src/main.py (modified)
# 3. Run tests
# Approve entire plan? [y/N]"
4. File Encoding Issues
Windows line endings (\r\n) will cause subtle bugs. Always normalize:
content = full_path.read_text(encoding="utf-8")
content = content.replace("\r\n", "\n") # Normalize line endings
5. Rate Limiting
Anthropic's API has rate limits. For production use, implement exponential backoff:
import time
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60)
)
async def _call_claude(self, messages):
return await self.client.messages.create(..)
What's Next
This agent is functional but basic. Here's what you'd add for production:
- Persistent state: Save conversation history to disk so you can resume interrupted sessions
- Multi-project support: Let Claude work across multiple repositories
- Custom tool definitions: Add project-specific tools like "run linter" or "deploy to staging"
- Audit logging: Record every action Claude takes for compliance
The complete source code for this tutorial is available on GitHub. Try running python cli.py init to generate a config, then python cli.py run "add type hints to all Python files" to see it in action.
Remember: Claude Code is a tool, not a replacement for understanding your codebase. Always review changes before committing them to production.
Was this article helpful?
Let us know to improve our AI generation.
Related Articles
How to Build an LLM from Scratch with PyTorch
Practical tutorial: It encourages community experimentation with AI for coding, which is valuable but not a major industry shift.
How to Build a Smart Speaker with Gemini Integration
Practical tutorial: It highlights a product update and strategic decision by Google, indicating a smart speaker with potential but delays in
How to Deploy a Custom Transformer for Text Classification in 2026
Practical tutorial: Explaining a specific AI model type is useful for the technical community but doesn't represent a major industry shift.