Building an AI coding agent from first principles
while True: response = model(messages, tools) if response.stop_reason != "tool_use": return response.text results = execute(response.tool_calls) messages.append(results)
Every agent you've ever used is a variation of these 6 lines.
Scenario: User asks "What files are in this project?"
A complete AI agent in ~100 lines. Single tool: bash
# The ONE tool that does everything T = [{ "name": "bash", "description": "Shell cmd. Read:cat/grep. Write:echo>/sed. Subagent: python v0_bash_agent_mini.py 'task'", "input_schema": { "type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"], }, }] # The agent loop def chat(prompt, history=[]): history.append({"role": "user", "content": prompt}) while (r := client.messages.create( model=M, system=S, messages=history, tools=T, max_tokens=8000 )).stop_reason == "tool_use": # ... execute tools, append results ... return "..."
The tool description IS the prompt engineering. It teaches the model how to use bash and how to spawn subagents.
# Parent agent Main Agent |-- bash: python v0_bash_agent.py "analyze architecture" | |-- Subagent (fresh history=[]) |-- bash: find . -name "*.py" |-- bash: cat src/main.py |-- Returns summary via stdout
Core insight: "Model as Agent" — 4 essential tools cover 90% of coding tasks
| Tool | Purpose |
|---|---|
| bash | Run any command |
| read_file | Read file contents |
| write_file | Create/overwrite files |
| edit_file | Surgical text replacement |
Why not just bash? Dedicated tools give:
# The agent loop core (v1) def agent_loop(messages): while True: response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000 ) if response.stop_reason != "tool_use": return messages for tc in tool_calls: output = execute_tool(tc.name, tc.input) results.append({...}) messages.append(results)
Anti-pattern: Giant Swiss-army-knife tools with 20 parameters
Principle: Start with 3-5 atomic tools, add more only when the agent consistently fails
# Actual tool schema from v1 { "name": "edit_file", "description": "Replace exact text in a file. Use for surgical edits.", "input_schema": { "type": "object", "properties": { "path": {"type": "string"}, "old_text": {"type": "string", "description": "Exact text to find (must match)"}, "new_text": {"type": "string"} }, "required": ["path", "old_text", "new_text"] } }
# The complete v1 system prompt SYSTEM = f"""You are a coding agent at {WORKDIR}. Loop: think briefly -> use tools -> report results. Rules: - Prefer tools over prose. - Never invent file paths. - Make minimal changes. - After finishing, summarize."""
# Bad: vague description "description": "Run a command" # Good: teaches the model patterns "description": """Execute shell command. Common patterns: - Read: cat/head/tail, grep/find - Write: echo 'x' > file, sed - Subagent: python v0.py 'task' (spawns isolated agent)"""
The precise description teaches the model how to use the tool effectively. The vague one leaves the model guessing.
Scenario: "Fix the typo in README.md where it says 'teh' instead of 'the'"
Key insight: The model chose to read first, then edit. Nobody told it to. The loop + tools + model intelligence = emergent agent behavior.
Core insight: "Make Plans Explicit"
"Constraints aren't limitations — they're scaffolding"
class TodoManager: def update(self, items): # Validate each item for item in items: if status == "in_progress": in_progress += 1 # Enforce constraints if len(validated) > 20: raise ValueError("Max 20") if in_progress > 1: raise ValueError("Only one") self.items = validated return self.render() def render(self): # [x] Completed [>] Working [ ] Pending ...
Scenario: "Add a /health endpoint to the API with tests"
Core insight: "Divide and Conquer with Context Isolation"
| Type | Tools | Purpose |
|---|---|---|
| explore | bash, read_file | Read-only research |
| code | all tools | Full implementation |
| plan | bash, read_file | Design/architecture |
def run_task(desc, prompt, agent_type): config = AGENT_TYPES[agent_type] # ISOLATED context - the key! sub_messages = [ {"role": "user", "content": prompt} ] # Filtered tools for this agent type sub_tools = get_tools_for_agent(agent_type) # Same agent loop, different context while True: response = client.messages.create( model=MODEL, system=sub_system, messages=sub_messages, tools=sub_tools, max_tokens=8000 ) if response.stop_reason != "tool_use": break # ... execute tools ... # Return ONLY the final summary return block.text
Core insight: "Knowledge Externalization"
| Approach | Cost | Timeline |
|---|---|---|
| Fine-tuning | $1K–$1M+ | Weeks |
| SKILL.md file | Free | Minutes |
Like hot-swappable LoRA adapters without any training!
class SkillLoader: def load_skills(self): # Scan skills/ directory for skill_dir in self.skills_dir.iterdir(): skill_md = skill_dir / "SKILL.md" if skill_md.exists(): skill = self.parse_skill_md(skill_md) self.skills[skill["name"]] = skill def get_skill_content(self, name): # Layer 2: full body on-demand skill = self.skills[name] content = skill["body"] # Layer 3: list available resources for folder in ["scripts", "references"]: ... return content
--- name: code-review description: Perform thorough code reviews with security, performance, and maintainability analysis. --- # Code Review Skill ## Review Checklist ### 1. Security (Critical) - [ ] Injection vulnerabilities - [ ] Authentication issues - [ ] Authorization flaws - [ ] Data exposure ### 2. Correctness - [ ] Logic errors, off-by-one - [ ] Race conditions - [ ] Resource leaks ### 3. Performance - [ ] N+1 queries - [ ] Blocking operations - [ ] Missing caches
Skills go in tool_result messages, not system prompt. System prompt unchanged = prompt cache hit. Saves ~90% on repeated content.
# Path traversal prevention def safe_path(p: str) -> Path: path = (WORKDIR / p).resolve() if not path.is_relative_to(WORKDIR): raise ValueError(f"Path escapes workspace: {p}") return path # Dangerous command blocking dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] if any(d in command for d in dangerous): return "Error: Dangerous command blocked" # Output truncation (prevent context overflow) return output[:50000] # Timeout enforcement subprocess.run(cmd, timeout=60)
Production Claude Code adds: permission system, hooks, sandboxing
# REQUEST POST /v1/messages { "model": "claude-sonnet-4-5-20250929", "system": "You are a coding agent...", "messages": [ {"role": "user", "content": "Fix the bug in auth.py"} ], "tools": [ {"name": "bash", ...}, {"name": "read_file", ...}, {"name": "edit_file", ...} ], "max_tokens": 8000 }
# RESPONSE { "content": [ {"type": "text", "text": "Let me read the file..."}, {"type": "tool_use", "name": "read_file", "input": {"path": "auth.py"}} ], "stop_reason": "tool_use" }
Every "AI agent" is:
The "magic" is in the model's responses, not the API.
| Claude Sonnet Pricing | |
|---|---|
| Input tokens | ~$3 / million |
| Output tokens | ~$15 / million |
| 200K context at capacity | ~$0.60 per call |
| Output tokens cost | 5x input |
This drives EVERYTHING:
The art: minimize tokens while maximizing useful context.
class UnifiedAPIClient: def __init__(self, provider=None): self.provider = provider or \ os.getenv("API_PROVIDER", "anthropic") if self.provider == "anthropic": self.client = Anthropic(...) self.default_model = "claude-sonnet-4-5-..." elif self.provider == "openai": self.client = OpenAI(...) self.default_model = "gpt-4"
Swap providers with one env var:
API_PROVIDER=openai
| Aspect | Translation |
|---|---|
| Tool schemas | Anthropic ↔ OpenAI functions |
| Messages | content blocks ↔ tool_calls |
| Stop reasons | "tool_use" ↔ "tool_calls" |
| System prompt | separate param ↔ in messages |
Why it matters: avoid vendor lock-in, test across models, use the best model for each task.
# Python MCP Server (minimal) from mcp.server import Server server = Server("my-server") @server.tool() async def hello(name: str) -> str: """Say hello.""" return f"Hello, {name}!" # Register in ~/.claude/mcp.json { "mcpServers": { "my-server": { "command": "python3", "args": ["/path/to/server.py"] } } }
Each version builds on the previous while maintaining the core agent loop pattern.
| Learn Repo | Production Claude Code |
|---|---|
| bash, read_file, write_file, edit_file | Read, Write, Edit, Bash, Glob, Grep |
| TodoManager | TodoWrite / TaskCreate |
| Task (subagents) | Task tool (Explore, Plan, Code agents) |
| SKILL.md files | Skills (/commit, /review-pr, etc.) |
| api_client.py | Multi-provider support |
| safe_path, cmd blocking | Permission system, hooks, sandboxing |
| — | + MCP servers, context compression, streaming |
The patterns are identical. Production adds scale, polish, and security — but the architecture is the same.
Without constraints: agents ramble, lose focus, waste tokens
Analogy
Poetic forms (sonnets, haiku) produce better poetry than "write anything".
Agent constraints produce better agents than "do anything".
github.com/anthropics/learn-claude-code