Introduction
You've learned the theory—ReAct loops, tool use, memory systems, and planning strategies. Now it's time to build. But before writing code, you need to make key design decisions that will shape your agent's capabilities, limitations, and overall success.
This chapter guides you through building your first complete agent, starting with the critical design decisions that differentiate a well-architected agent from a fragile prototype.
Core Principle: The best agents are built from clear requirements, not accumulated features. Define what your agent must do before deciding how it will do it.
When to Build an Agent
Not every AI application needs to be an agent. Before investing in agent architecture, consider whether simpler approaches might suffice:
| Approach | Best For | Example |
|---|---|---|
| Single LLM call | One-shot tasks with clear inputs | Summarizing a document |
| Prompt chain | Multi-step but predictable workflows | Generate → Review → Refine |
| RAG pipeline | Knowledge retrieval with generation | Answering questions from docs |
| Agent | Dynamic, multi-step, tool-using tasks | Research and write a report |
Signs You Need an Agent
- Dynamic tool selection: The tools needed depend on intermediate results
- Iterative refinement: Outputs need review and improvement loops
- Multi-step reasoning: The task requires planning and execution
- Error recovery: The system must adapt when things fail
- Stateful interaction: Context must persist across multiple exchanges
Signs You Don't Need an Agent
- Fixed workflow: Steps are always the same
- Single tool: Only one capability is ever needed
- No iteration: First output is always acceptable
- Latency-critical: Response time must be under 1 second
1# Decision helper
2def should_use_agent(task_description: str) -> dict:
3 """Analyze whether a task needs an agent."""
4
5 characteristics = {
6 "requires_multiple_tools": False,
7 "dynamic_tool_selection": False,
8 "needs_iteration": False,
9 "requires_planning": False,
10 "stateful": False,
11 "error_recovery_needed": False,
12 }
13
14 # Analyze task...
15 # (In practice, you'd use LLM or heuristics here)
16
17 score = sum(characteristics.values())
18
19 return {
20 "characteristics": characteristics,
21 "agent_score": score,
22 "recommendation": "agent" if score >= 3 else "simpler_approach",
23 "reasoning": f"{score}/6 agent characteristics detected"
24 }Architecture Choices
Once you've decided to build an agent, you need to choose its architecture. Here are the main patterns:
1. Simple ReAct Agent
A straightforward thought-action-observation loop. Best for focused tasks with clear tool boundaries.
1class SimpleReActAgent:
2 """Basic ReAct agent for focused tasks."""
3
4 def __init__(self, tools: list, model: str):
5 self.tools = {t.name: t for t in tools}
6 self.model = model
7 self.max_steps = 10
8
9 async def run(self, task: str) -> str:
10 history = []
11
12 for step in range(self.max_steps):
13 # Think and act
14 response = await self.step(task, history)
15
16 if response.get("final_answer"):
17 return response["final_answer"]
18
19 # Execute action
20 tool_name = response["action"]
21 tool_input = response["action_input"]
22 result = await self.tools[tool_name].execute(tool_input)
23
24 history.append({
25 "thought": response["thought"],
26 "action": tool_name,
27 "observation": result
28 })
29
30 return "Max steps reached without answer"2. Planning Agent
Creates a plan before execution, then follows it with monitoring. Best for complex, multi-phase tasks.
1class PlanningAgent:
2 """Agent that plans before executing."""
3
4 def __init__(self, tools: list, model: str):
5 self.tools = {t.name: t for t in tools}
6 self.model = model
7 self.planner = TaskPlanner(model)
8 self.executor = TaskExecutor(tools)
9
10 async def run(self, task: str) -> str:
11 # Phase 1: Plan
12 plan = await self.planner.create_plan(task)
13
14 # Phase 2: Execute with monitoring
15 results = []
16 for step in plan.steps:
17 result = await self.executor.execute(step)
18 results.append(result)
19
20 # Check if replanning needed
21 if not result.success:
22 plan = await self.planner.replan(task, results)
23
24 # Phase 3: Synthesize
25 return await self.synthesize(task, results)3. Hierarchical Agent
A supervisor agent delegates to specialized sub-agents. Best for broad domains requiring multiple expertise areas.
1class HierarchicalAgent:
2 """Supervisor with specialized sub-agents."""
3
4 def __init__(self, sub_agents: dict[str, Agent]):
5 self.sub_agents = sub_agents
6 self.supervisor = SupervisorLLM()
7
8 async def run(self, task: str) -> str:
9 # Supervisor decides delegation
10 delegation = await self.supervisor.analyze(task)
11
12 results = {}
13 for subtask in delegation.subtasks:
14 agent = self.sub_agents[subtask.agent_type]
15 results[subtask.id] = await agent.run(subtask.description)
16
17 # Supervisor synthesizes
18 return await self.supervisor.synthesize(task, results)Choosing an Architecture
| Architecture | Complexity | Best For |
|---|---|---|
| Simple ReAct | Low | Single-domain tasks, quick iterations |
| Planning Agent | Medium | Multi-step tasks, need for oversight |
| Hierarchical | High | Multi-domain tasks, specialized expertise |
Model Selection
The model powering your agent significantly impacts its capabilities and cost. Here's how to choose:
Model Capabilities Matrix
| Capability | Claude Haiku | Claude Sonnet | Claude Opus |
|---|---|---|---|
| Speed | Fastest | Balanced | Slower |
| Cost | Lowest | Moderate | Highest |
| Reasoning | Good | Excellent | Best |
| Tool use | Good | Excellent | Excellent |
| Long context | Good | Excellent | Excellent |
| Complex planning | Limited | Good | Excellent |
Model Selection Strategy
1from enum import Enum
2
3class ModelTier(Enum):
4 FAST = "claude-3-5-haiku-20241022" # Quick, cheap operations
5 BALANCED = "claude-sonnet-4-20250514" # Most agent tasks
6 POWERFUL = "claude-opus-4-20250514" # Complex reasoning
7
8class ModelSelector:
9 """Select appropriate model based on task."""
10
11 def select(self, task_type: str, complexity: str) -> str:
12 """Choose model based on task characteristics."""
13
14 # Simple lookups, formatting -> Fast model
15 if task_type in ["lookup", "format", "simple_qa"]:
16 return ModelTier.FAST.value
17
18 # Most agent tasks -> Balanced model
19 if complexity in ["low", "medium"]:
20 return ModelTier.BALANCED.value
21
22 # Complex reasoning, planning -> Powerful model
23 if complexity == "high" or task_type in ["planning", "analysis"]:
24 return ModelTier.POWERFUL.value
25
26 return ModelTier.BALANCED.value # DefaultMulti-Model Agents
Advanced agents use different models for different operations:
1class MultiModelAgent:
2 """Agent using different models for different tasks."""
3
4 def __init__(self):
5 self.models = {
6 "planning": "claude-opus-4-20250514", # Complex planning
7 "execution": "claude-sonnet-4-20250514", # Tool use, reasoning
8 "summarization": "claude-3-5-haiku-20241022", # Quick summaries
9 }
10 self.clients = {name: Anthropic() for name in self.models}
11
12 async def plan(self, task: str) -> Plan:
13 """Use powerful model for planning."""
14 client = self.clients["planning"]
15 # ... planning logic
16
17 async def execute_step(self, step: Step) -> Result:
18 """Use balanced model for execution."""
19 client = self.clients["execution"]
20 # ... execution logic
21
22 async def summarize(self, results: list) -> str:
23 """Use fast model for summarization."""
24 client = self.clients["summarization"]
25 # ... summarization logicTool Strategy
Tools are your agent's interface with the world. Poor tool design is a common cause of agent failure.
Tool Design Principles
- Single responsibility: Each tool does one thing well
- Clear interfaces: Unambiguous inputs and outputs
- Informative errors: Failures explain what went wrong
- Reasonable defaults: Optional parameters have sensible defaults
- Composable: Tools can be combined for complex operations
1from dataclasses import dataclass
2from typing import Any
3
4@dataclass
5class ToolDefinition:
6 """Well-defined tool interface."""
7 name: str
8 description: str
9 parameters: dict[str, dict] # JSON Schema
10 required_params: list[str]
11 examples: list[dict] # Example calls
12
13# Good tool definition
14search_tool = ToolDefinition(
15 name="web_search",
16 description="Search the web for information. Returns top results with snippets.",
17 parameters={
18 "query": {
19 "type": "string",
20 "description": "Search query (be specific for better results)"
21 },
22 "num_results": {
23 "type": "integer",
24 "description": "Number of results to return",
25 "default": 5,
26 "minimum": 1,
27 "maximum": 20
28 }
29 },
30 required_params=["query"],
31 examples=[
32 {"query": "Python async programming tutorial", "num_results": 3},
33 {"query": "latest React 19 features"}
34 ]
35)
36
37# Bad tool definition (avoid)
38bad_tool = ToolDefinition(
39 name="search", # Too generic
40 description="Searches things", # Vague
41 parameters={"q": {"type": "string"}}, # Cryptic param name
42 required_params=["q"],
43 examples=[] # No examples
44)Tool Categories
| Category | Examples | Considerations |
|---|---|---|
| Information retrieval | Web search, database query, API call | Rate limits, caching, error handling |
| Content creation | Write file, generate image, send email | Confirmation for destructive actions |
| Computation | Calculator, code execution, data analysis | Sandboxing, timeouts, resource limits |
| External services | Slack, GitHub, calendar | Authentication, permissions, quotas |
Tool Count Guidelines
- 3-5 tools: Optimal for focused agents
- 6-10 tools: Manageable with good descriptions
- 10+ tools: Consider tool categorization or hierarchical selection
1class ToolRegistry:
2 """Manage agent tools with categories."""
3
4 def __init__(self):
5 self.tools: dict[str, Tool] = {}
6 self.categories: dict[str, list[str]] = {}
7
8 def register(self, tool: Tool, category: str) -> None:
9 """Register a tool in a category."""
10 self.tools[tool.name] = tool
11 if category not in self.categories:
12 self.categories[category] = []
13 self.categories[category].append(tool.name)
14
15 def get_tools_for_task(self, task: str) -> list[Tool]:
16 """Get relevant tools for a task (could use LLM selection)."""
17 # For now, return all tools
18 # In production, filter based on task analysis
19 return list(self.tools.values())
20
21 def get_tool_descriptions(self) -> str:
22 """Format tool descriptions for prompt."""
23 lines = []
24 for category, tool_names in self.categories.items():
25 lines.append(f"\n## {category}")
26 for name in tool_names:
27 tool = self.tools[name]
28 lines.append(f"- {tool.name}: {tool.description}")
29 return "\n".join(lines)Design Checklist
Before building, work through this checklist:
Requirements
- What specific tasks must the agent accomplish?
- What are the success criteria for each task?
- What are the failure modes and how should they be handled?
- What are the latency and cost constraints?
Architecture
- Which architecture pattern fits the requirements?
- What is the maximum number of steps allowed?
- How will the agent know when it's done?
- What state needs to persist between steps?
Tools
- What tools are essential vs. nice-to-have?
- Are tool descriptions clear and unambiguous?
- What happens when tools fail?
- Are there any dangerous tools that need confirmation?
Model
- What model capabilities are required?
- Should different operations use different models?
- What is the token budget per task?
- How will context length be managed?
Safety
- What actions should require human confirmation?
- What are the rate limits and resource bounds?
- How will sensitive data be handled?
- What logging and auditing is required?
1@dataclass
2class AgentDesign:
3 """Document agent design decisions."""
4
5 # Requirements
6 name: str
7 purpose: str
8 success_criteria: list[str]
9 failure_handling: str
10
11 # Architecture
12 architecture: str # "react", "planning", "hierarchical"
13 max_steps: int
14 termination_conditions: list[str]
15
16 # Tools
17 tools: list[ToolDefinition]
18 dangerous_tools: list[str] # Require confirmation
19
20 # Model
21 primary_model: str
22 model_overrides: dict[str, str] # operation -> model
23
24 # Safety
25 require_confirmation: list[str] # Action types
26 rate_limits: dict[str, int]
27 max_tokens_per_task: int
28
29 def validate(self) -> list[str]:
30 """Validate design completeness."""
31 issues = []
32
33 if not self.success_criteria:
34 issues.append("No success criteria defined")
35
36 if not self.tools:
37 issues.append("No tools defined")
38
39 if self.max_steps < 1:
40 issues.append("Invalid max_steps")
41
42 for dangerous in self.dangerous_tools:
43 if dangerous not in [t.name for t in self.tools]:
44 issues.append(f"Dangerous tool {dangerous} not in tool list")
45
46 return issuesSummary
Good agent design starts with clear decisions about what you're building and why. We covered:
- When to build an agent: Agents are for dynamic, multi-step, tool-using tasks—not every AI application
- Architecture choices: Simple ReAct, Planning, or Hierarchical based on complexity
- Model selection: Match model capabilities to task requirements, consider multi-model strategies
- Tool strategy: Well-designed tools with clear interfaces are essential
- Design checklist: Work through requirements, architecture, tools, model, and safety decisions upfront
In the next section, we'll implement the core agent loop—the beating heart of any agent system.